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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user