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