feat(recovery): failure-ticket routing + review-gate RECOVER + return-to-sender

Retry-agency invariant: a stage may only retry a gate it has the capability
to change. A write-less stage failing a build/contract/static gate (e.g.
freestyle final_verification with allowedTools=[shell]) can never fix it, so
retrying in place is futile until the budget drains (Vikunja #41).

Instead: open a FailureTicketOpenedEvent (category + requiredCapability derived
deterministically from the gate id, no LLM) and route to a recovery stage that
holds the capability, bounded by a small per-stage route budget.

- Slice 2: SalvageDecision.RECOVER (ternary CONTINUE/RECOVER/FAIL) lets the
  review-gate salvage judge hand off to recovery. Shared routeToRecovery()
  unifies the deterministic agency guard and the judge on one destination;
  decideGateExhaustion now returns StepResult?.
- Return-to-sender: recovery's exit is dynamic (recoveryReturnMove) — it goes
  back to the exact ticket-origin stage to re-run its gate, so a write-less
  gate anywhere in the graph is handled without skipping intervening stages.
  The synthesized recovery->terminal edge is now only a no-ticket fallback.
- Freestyle wiring: ExecutionPlanCompiler injectRecovery flag (Main passes
  true) synthesizes a write-capable recovery stage; ticket evidence reaches it
  via buildRecoveryTicketEntry.
- Read-only tools always present: StageConfig.effectiveAllowedTools adds
  file_read/list_dir to any tool-granting stage (fixes the verifier reaching
  for `shell ls -R` to inspect the tree).

Tests: RecoveryRoutingTest (deterministic gate, no static return edge — proves
dynamic return) + GateRetryBudgetExhaustionTest RECOVER case + two compiler
tests. All green; detekt clean.
This commit is contained in:
2026-07-07 12:05:26 +04:00
parent 597a26d551
commit 879672a47d
13 changed files with 672 additions and 23 deletions
@@ -69,9 +69,45 @@ data class RetryAttemptedEvent(
val charged: Boolean = true,
) : EventPayload
/** Outcome of the hybrid-exhaustion salvage judgement for the semantic review gate. */
/**
* Outcome of the hybrid-exhaustion salvage judgement for the semantic review gate.
* CONTINUE resets the gate budget and retries the reviewed stage in place; FAIL is terminal;
* RECOVER hands the failure to the recovery stage (same destination as the deterministic-gate
* agency guard) — safe under invariant #7 because recovery output re-enters verification and so
* cannot corrupt state, it only chooses which bounded remediation to spend budget on.
*/
@Serializable
enum class SalvageDecision { CONTINUE, FAIL }
enum class SalvageDecision { CONTINUE, FAIL, RECOVER }
/**
* A verification gate failed on a stage that lacks the capability to change the failure condition
* (the retry-agency invariant: recovery/ticket model). Rather than retry the same write-less stage
* in place — which is futile by construction (live repro: freestyle `final_verification` compiled
* with `allowedTools=[shell]` re-ran the identical broken `npm run build` until the budget drained,
* never able to touch a file) — the orchestrator opens a failure ticket and routes to a recovery
* stage that DOES hold the capability. [category] and [requiredCapability] are derived
* deterministically from [gate] (no LLM), so this drives routing without violating invariant #7.
* [evidence] is the gate's captured failure output; it is recorded here at ticket-open time and
* replay reads it back (invariant #9) — the gate command is never re-run on replay.
*/
@Serializable
@SerialName("FailureTicketOpened")
data class FailureTicketOpenedEvent(
val sessionId: SessionId,
// The stage that failed the gate but lacks the capability to fix it (the write-less verifier).
val stageId: StageId,
val gate: String,
// Deterministic from [gate]: "implementation" | "planning" | "environment".
val category: String,
// The capability the failing stage lacks (e.g. "file_write"); the recovery stage must hold it.
val requiredCapability: String,
// The recovery stage the ticket is routed to.
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).
val routeAttempt: Int,
) : EventPayload
/**
* Recorded when a gate exhausts its retry budget and (per design decision 3) the salvage judge is
@@ -25,4 +25,10 @@ data class OrchestrationState(
// Gates that have already spent their one hybrid-exhaustion salvage reset (review gate only,
// see RetrySalvageDecidedEvent) — a second exhaustion for that gate is terminal.
val gateSalvageUsed: Set<String> = emptySet(),
// Recovery-routing budget (retry-agency / ticket model): how many times each stage id has been
// routed to a recovery stage because it failed a gate it lacked the capability to fix (see
// FailureTicketOpenedEvent). Deliberately NOT reset by TransitionExecutedEvent — the
// verification→recovery→verification loop must stay bounded across those transitions, exactly as
// refinementIterations does for back-edge loops.
val recoveryRoutes: Map<String, Int> = emptyMap(),
)
@@ -59,6 +59,7 @@ import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
import com.correx.core.events.events.RepoMapComputedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SourceFetchedEvent
@@ -146,6 +147,7 @@ val eventModule = SerializersModule {
subclass(WorkflowCompletedEvent::class)
subclass(RetryAttemptedEvent::class)
subclass(RetrySalvageDecidedEvent::class)
subclass(FailureTicketOpenedEvent::class)
subclass(RefinementIterationEvent::class)
subclass(RepoMapComputedEvent::class)
subclass(WorkspaceStateObservedEvent::class)