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:
@@ -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(".")
|
||||
}
|
||||
|
||||
+26
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user