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
+23
View File
@@ -211,6 +211,29 @@ type Session struct {
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
// LastReview is the most recent semantic reviewer (Gate 3) verdict for this
// session, from review.findings. Nil until the first review lands.
LastReview *ReviewResult
}
// ReviewResult is the semantic reviewer's verdict + findings over a stage's
// produced files (mirrors ReviewFindingsRaisedEvent).
type ReviewResult struct {
StageID string
Verdict string // PASS | WARN | FAIL
Findings []ReviewFinding
Blocked bool
}
type ReviewFinding struct {
Severity string
Confidence float64
Category string
Target string
Message string
SuggestedFix string
Correctness bool
}
// Workflow is a launchable workflow advertised by the server.
@@ -296,6 +296,21 @@ func matrixCases() []matrixCase {
},
want: []string{"ToolAssessed", "BLOCK", "PATH_ESCAPE"},
},
{
name: "review.findings",
kinds: []string{protocol.TypeReviewFindings},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeReviewFindings, withStage("write_script"), func(m *protocol.ServerMessage) {
m.ReviewVerdict = "FAIL"
m.ReviewBlocked = true
m.ReviewFindings = []protocol.ReviewFindingDto{
{Severity: "CRITICAL", Confidence: 0.9, Category: "correctness", Target: "main.go:12", Message: "nil deref"},
}
})
},
want: []string{"ReviewFindings", "write_script", "FAIL"},
},
// --- artifacts / plan (event stream rows) ---
{
+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 {
+11 -1
View File
@@ -225,7 +225,11 @@ func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) {
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
// edit mode, so it works whether or not the input is focused.
if m.displayState() == StateIdle && k.Type == keyTab {
m.cycleLauncherWf()
if k.Shift {
m.cycleLauncherWfBack()
} else {
m.cycleLauncherWf()
}
return m, nil
}
if m.editMode == ModeInsert {
@@ -1290,6 +1294,12 @@ func (m *Model) cycleLauncherWf() {
m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1)
}
// cycleLauncherWfBack is cycleLauncherWf in reverse, for Shift+Tab.
func (m *Model) cycleLauncherWfBack() {
n := len(m.workflows) + 1
m.launcherWf = (m.launcherWf - 1 + n) % n
}
// cycleRightPanel advances the in-session right panel: events → changes → off → events.
func (m *Model) cycleRightPanel() {
m.rightPanel = (m.rightPanel + 1) % 3
+3 -2
View File
@@ -908,7 +908,7 @@ func shortPath(p string) string {
// fill left-justifies a styled line and pads with bg to width w.
func (m Model) fill(s string, w int, bg color.Color) string {
return " " + padTo(s, w-1, bg)
return lipgloss.NewStyle().Background(bg).Render(" ") + padTo(s, w-1, bg)
}
// justify places left and right segments on a bg-filled line of width w, truncating
@@ -936,7 +936,8 @@ func (m Model) justify(left, right string, w int, bg color.Color) string {
mid = 1
}
pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", mid))
return " " + left + pad + right + lipgloss.NewStyle().Background(bg).Render(" ")
edge := lipgloss.NewStyle().Background(bg).Render(" ")
return edge + left + pad + right + edge
}
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
+21 -2
View File
@@ -60,6 +60,7 @@ const (
TypeHealthChecks = "health.checks"
TypeFileList = "file.list"
TypeGrantList = "grant.list"
TypeReviewFindings = "review.findings"
)
// ServerMessage is a flat decode of every server->client variant. Field names
@@ -82,7 +83,9 @@ type ServerMessage struct {
LastResponse string `json:"lastResponse"` // session_snapshot: last stage response (reopen)
Content string `json:"content"`
Diff *string `json:"diff"`
Tier string `json:"tier"`
// tool.completed: file/dir path(s) the tool read, wrote, or listed.
AffectedEntities []string `json:"affectedEntities"`
Tier string `json:"tier"`
Message string `json:"message"`
RequestID string `json:"requestId"`
ArtifactID string `json:"artifactId"`
@@ -136,6 +139,11 @@ type ServerMessage struct {
AssessedIssues []AssessedIssueDto `json:"issues"`
Artifacts []ArtifactDto `json:"artifacts"`
// review.findings
ReviewVerdict string `json:"verdict"`
ReviewFindings []ReviewFindingDto `json:"findings"`
ReviewBlocked bool `json:"blocked"`
// config.snapshot
ConfigFields []ConfigFieldDto `json:"fields"`
ConfigRestartRequired []string `json:"restartRequired"`
@@ -331,6 +339,17 @@ type AssessedIssueDto struct {
Severity string `json:"severity"`
}
// ReviewFindingDto is a PR-comment-style finding from the semantic reviewer (Gate 3).
type ReviewFindingDto struct {
Severity string `json:"severity"`
Confidence float64 `json:"confidence"`
Category string `json:"category"`
Target string `json:"target"`
Message string `json:"message"`
SuggestedFix *string `json:"suggestedFix"`
Correctness bool `json:"correctness"`
}
type ProviderHealthDto struct {
ProviderID string `json:"providerId"`
Status string `json:"status"`
@@ -396,7 +415,7 @@ func (m ServerMessage) IsEventBearing() bool {
TypeWorkflowProposed,
TypeWorkspaceBound, TypeArtifactCreated,
TypeArtifactValid, TypePlanLocked,
TypeRouterNarration:
TypeRouterNarration, TypeReviewFindings:
return true
default:
return false