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
@@ -14,6 +14,7 @@ import com.correx.core.events.events.ReviewFinding
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ReviewSeverity
import com.correx.core.events.events.ReviewVerdict
import com.correx.core.events.events.FailureTicketOpenedEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision
import com.correx.core.events.events.WorkflowFailedEvent
@@ -311,6 +312,61 @@ class GateRetryBudgetExhaustionTest {
assertTrue(blockedReviews >= 2, "expected at least 2 blocked review attempts (pre- and post-salvage)")
}
// Stage A opts into the (always-failing) review gate; a sibling recovery stage holds file_write.
// Recovery has NO outgoing edge — the kernel returns it to the ticket origin (A) dynamically.
private fun recoveryReviewGraph(): WorkflowGraph = WorkflowGraph(
id = "gate-review-recover-test",
stages = mapOf(
StageId("A") to StageConfig(allowedTools = setOf("file_write"), semanticReview = true),
StageId("recovery") to StageConfig(
allowedTools = setOf("file_write"),
metadata = mapOf("role" to "recovery"),
),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
@Test
fun `review gate exhaustion with RECOVER salvage routes to the recovery stage and exhausts the route budget`(): Unit =
runBlocking {
// Slice 2: the salvage judge chooses RECOVER instead of CONTINUE/FAIL, so a review-gate
// exhaustion hands off to the recovery stage (same destination as the deterministic agency
// guard) rather than retrying A in place or failing outright. Bounded by RECOVERY_ROUTE_BUDGET.
val salvageJudge = com.correx.core.kernel.orchestration.SalvageJudge { _, _, _ ->
SalvageJudgment(SalvageDecision.RECOVER, "hand this to recovery")
}
val (orchestrator, eventStore) = buildOrchestrator(alwaysFailingReviewer, salvageJudge)
val sessionId = SessionId("gate-review-recover")
val config = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 3, backoffMs = 0, perGateMaxAttempts = mapOf("review" to 1)),
)
val result = orchestrator.run(sessionId, recoveryReviewGraph(), config)
assertTrue(result is WorkflowResult.Failed, "route budget must exhaust terminally")
assertTrue((result as WorkflowResult.Failed).retryExhausted)
val events = eventStore.read(sessionId)
val tickets = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
assertEquals(2, tickets.size, "one ticket per route, bounded by RECOVERY_ROUTE_BUDGET")
tickets.forEach {
assertEquals("A", it.stageId.value)
assertEquals("review", it.gate)
assertEquals("implementation", it.category)
assertEquals("file_write", it.requiredCapability)
assertEquals("recovery", it.routeTo.value)
}
assertEquals(listOf(1, 2), tickets.map { it.routeAttempt })
// Every salvage decision on this path is RECOVER (judge consulted afresh each re-entry
// because TransitionExecuted resets gateSalvageUsed).
val salvageDecisions = events.mapNotNull { it.payload as? RetrySalvageDecidedEvent }
assertTrue(salvageDecisions.size >= 2, "judge consulted on each review exhaustion")
assertTrue(salvageDecisions.all { it.decision == SalvageDecision.RECOVER })
}
@Test
fun `review gate exhaustion with FAIL salvage is terminal immediately`(): Unit = runBlocking {
val salvageJudge = com.correx.core.kernel.orchestration.SalvageJudge { _, _, _ ->