feat(recovery): route failed write-less stages to recovery instead of futile retry

Adds the failure-ticket + recovery-routing mechanism: a deterministic
gate->capability table gates whether a stage has agency to fix its own
failure, opens a FailureTicketOpenedEvent when it doesn't, and routes to
a metadata role=recovery stage (per-stage budget, cap 2, not reset by
TransitionExecuted) instead of retrying in place. Extends salvage
decisions with a RECOVER option so the review-gate judge can also route
to recovery, unifies deterministic-gate and review-gate routing through
routeToRecovery(), and has ExecutionPlanCompiler synthesize a
write-capable recovery stage + edge for freestyle plans. Read-only tools
(file_read, list_dir) are now always available on any tool-granting
stage so a recovery stage can inspect the write-less stage's failure
without flooding context via shell ls -R.
This commit is contained in:
2026-07-08 10:28:53 +04:00
parent d6bada6f10
commit f51a8dada4
21 changed files with 325 additions and 29 deletions
+46 -1
View File
@@ -221,7 +221,52 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
} else {
m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary))
label := msg.ToolName
if len(msg.AffectedEntities) > 0 {
label += " " + strings.Join(msg.AffectedEntities, ", ")
}
m.appendAction(msg.SessionID, "✓", actionToolText(label, msg.Summary))
}
case protocol.TypeReviewFindings:
if s := m.session(msg.SessionID); s != nil {
findings := make([]ReviewFinding, 0, len(msg.ReviewFindings))
for _, f := range msg.ReviewFindings {
fix := ""
if f.SuggestedFix != nil {
fix = *f.SuggestedFix
}
findings = append(findings, ReviewFinding{
Severity: f.Severity,
Confidence: f.Confidence,
Category: f.Category,
Target: f.Target,
Message: f.Message,
SuggestedFix: fix,
Correctness: f.Correctness,
})
}
s.LastReview = &ReviewResult{
StageID: msg.StageID,
Verdict: msg.ReviewVerdict,
Findings: findings,
Blocked: msg.ReviewBlocked,
}
detail := msg.ReviewVerdict
if len(findings) > 0 {
detail += " (" + plural(len(findings), "finding") + ")"
}
if msg.ReviewBlocked {
detail += " · blocked"
}
s.LastOutput = "review: " + detail
s.addEvent(nowMillis(), "ReviewFindings", msg.StageID+": "+detail)
icon := "✓"
if msg.ReviewVerdict == "FAIL" {
icon = "✕"
} else if msg.ReviewVerdict == "WARN" {
icon = "▲"
}
m.appendAction(msg.SessionID, icon, "review "+detail)
}
case protocol.TypeToolAssessed:
if s := m.session(msg.SessionID); s != nil {