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
@@ -52,4 +52,20 @@ data class StageConfig(
// inference), so a docs-only plan is left untouched. Explicit build_expectation always wins.
val autoBuildGate: Boolean = false,
val metadata: Map<String, String> = emptyMap(),
)
) {
/**
* The tools actually permitted at run time: the stage's declared [allowedTools] plus the
* always-available read-only tools ([ALWAYS_AVAILABLE_READ_TOOLS]). A stage that grants any tool
* can always inspect the workspace (read a file, list a dir) without the planner having to
* remember to list them — this is why a write-less verifier could `shell ls -R` (flooding its
* own context) instead of a scoped `list_dir`. A stage that declares NO tools stays toolless (a
* pure-reasoning stage is left as designed — read tools would flip it into tool-calling mode).
*/
val effectiveAllowedTools: Set<String>
get() = if (allowedTools.isEmpty()) allowedTools else allowedTools + ALWAYS_AVAILABLE_READ_TOOLS
companion object {
/** Read-only tools every tool-granting stage may call regardless of its declared set. */
val ALWAYS_AVAILABLE_READ_TOOLS: Set<String> = setOf("file_read", "list_dir")
}
}