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