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