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
@@ -105,8 +105,16 @@ data class FailureTicketOpenedEvent(
val routeTo: StageId,
// The gate's captured failure output (command/exit/stderr/findings) that recovery must resolve.
val evidence: String,
// 1-based count of times this stage has been routed to recovery (own, small route budget).
// Count of NO-PROGRESS routes charged against this stage's recovery budget so far. A route whose
// failure fingerprint changed from the previous route (recovery made progress — a different error
// now) is FREE and leaves this flat; only a route that reproduces the same failure charges it. So
// a multi-cause failure repaired one layer at a time is not penalised for progressing. Folded
// directly by the reducer (idempotent under replay). Defaulted for backward-compatible replay of
// pre-progress-aware tickets.
val routeAttempt: Int,
// Fingerprint of [evidence] (whitespace/digit-normalised, see FailureFingerprint) at route time.
// The next route compares against this to decide progress vs. no-progress. Empty on old events.
val fingerprint: String = "",
) : EventPayload
/**
@@ -31,4 +31,11 @@ data class OrchestrationState(
// verification→recovery→verification loop must stay bounded across those transitions, exactly as
// refinementIterations does for back-edge loops.
val recoveryRoutes: Map<String, Int> = emptyMap(),
// Last-seen failure fingerprint per recovering stage id — the recovery-routing analogue of
// gateFailureFingerprints. Lets routeToRecovery tell a no-progress route (same fingerprint,
// charged against recoveryRoutes) from a genuine-progress one (changed fingerprint, free), so a
// recovery that fixes one cause and surfaces the next is not killed by the small route budget.
// Like recoveryRoutes, NOT reset by TransitionExecutedEvent — it must survive the
// verification→recovery→verification round-trip.
val recoveryFailureFingerprints: Map<String, String> = emptyMap(),
)