feat(recovery): route failed write-less stages to recovery instead of futile retry

Adds the failure-ticket + recovery-routing mechanism: a deterministic
gate->capability table gates whether a stage has agency to fix its own
failure, opens a FailureTicketOpenedEvent when it doesn't, and routes to
a metadata role=recovery stage (per-stage budget, cap 2, not reset by
TransitionExecuted) instead of retrying in place. Extends salvage
decisions with a RECOVER option so the review-gate judge can also route
to recovery, unifies deterministic-gate and review-gate routing through
routeToRecovery(), and has ExecutionPlanCompiler synthesize a
write-capable recovery stage + edge for freestyle plans. Read-only tools
(file_read, list_dir) are now always available on any tool-granting
stage so a recovery stage can inspect the write-less stage's failure
without flooding context via shell ls -R.
This commit is contained in:
2026-07-08 10:28:53 +04:00
parent d6bada6f10
commit f51a8dada4
21 changed files with 325 additions and 29 deletions
@@ -145,6 +145,13 @@ object KindContractTable {
val overrides = projectOverrides[kindId].orEmpty()
.filter { ov -> base.none { it.id == ov.id } }
.map { it.copy(target = path) }
return base + overrides
// A dotfile (basename begins with '.') may legitimately be empty: .gitkeep/.keep directory
// placeholders, .nojekyll markers, an empty .gitignore. Asserting file_nonempty on one makes
// the contract unsatisfiable by construction — a real .gitkeep IS empty — which live-locks the
// producing stage rewriting the same placeholder every retry (observed: scaffold_frontend
// looping on four src/*/.gitkeep files). file_exists still applies, so the file must exist.
return (base + overrides).filterNot { it.id == "file_nonempty" && isDotfile(path) }
}
private fun isDotfile(path: String): Boolean = path.substringAfterLast('/').startsWith(".")
}
@@ -61,6 +61,32 @@ class KindContractTableTest {
assertTrue("registered_in_serialization" in ids)
}
@Test
fun `dotfiles are exempt from file_nonempty but still must exist`() {
// Regression: a .gitkeep placeholder is empty by definition, so a file_nonempty floor made
// its contract unsatisfiable and live-locked scaffold_frontend rewriting src/*/.gitkeep every
// retry. Any dotfile (basename begins with '.') may legitimately be empty.
val gitkeep = KindContractTable.assertionsFor(
KindInference.kindFor("frontend/src/hooks/.gitkeep") ?: "",
"frontend/src/hooks/.gitkeep",
).map { it.id }
assertTrue("file_exists" in gitkeep, "dotfile must still be required to exist")
assertTrue("file_nonempty" !in gitkeep, "an empty .gitkeep must not fail file_nonempty")
// A non-dotfile with the same unknown kind keeps the full floor.
val regular = KindContractTable.assertionsFor("", "frontend/src/hooks/placeholder.ts").map { it.id }
assertTrue("file_nonempty" in regular, "non-dotfiles keep the nonempty floor")
}
@Test
fun `dotfile exemption also strips a would-be file_nonempty project override`() {
KindContractTable.projectOverrides = mapOf(
"" to listOf(ContractAssertion("file_nonempty", "", AssertionEvaluator.FS)),
)
val ids = KindContractTable.assertionsFor("", "config/.nojekyll").map { it.id }
assertTrue("file_nonempty" !in ids, "dotfile exemption is final, even against overrides")
}
@Test
fun `project override is additive and cannot remove checked-in assertions`() {
KindContractTable.projectOverrides = mapOf(