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
@@ -2,6 +2,7 @@ package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.AssessedIssueDto
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ReviewFindingDto
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.toDto
@@ -22,6 +23,7 @@ import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.ReviewFindingsRaisedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
@@ -190,6 +192,7 @@ suspend fun domainEventToServerMessage(
outputSummary = p.receipt.outputSummary,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
diff = p.receipt.diff,
affectedEntities = p.receipt.affectedEntities,
sequence = seq,
sessionSequence = sessionSequence,
)
@@ -222,6 +225,26 @@ suspend fun domainEventToServerMessage(
sessionSequence = sessionSequence,
)
is ReviewFindingsRaisedEvent -> ServerMessage.ReviewFindings(
sessionId = p.sessionId,
stageId = p.stageId,
verdict = p.verdict.name,
findings = p.findings.map {
ReviewFindingDto(
severity = it.severity.name,
confidence = it.confidence,
category = it.category,
target = it.target,
message = it.message,
suggestedFix = it.suggestedFix,
correctness = it.correctness,
)
},
blocked = p.blocked,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
is ClarificationRequestedEvent -> ServerMessage.ClarificationRequired(
sessionId = p.sessionId,
@@ -19,6 +19,17 @@ data class AssessedIssueDto(
val severity: String,
)
@Serializable
data class ReviewFindingDto(
val severity: String,
val confidence: Double,
val category: String,
val target: String,
val message: String,
val suggestedFix: String? = null,
val correctness: Boolean = false,
)
@Serializable
data class ProviderHealthDto(
val providerId: String,
@@ -246,6 +246,7 @@ sealed interface ServerMessage {
val outputSummary: String,
val occurredAt: Long,
val diff: String? = null,
val affectedEntities: List<String> = emptyList(),
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
@@ -284,6 +285,18 @@ sealed interface ServerMessage {
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
@Serializable
@SerialName("review.findings")
data class ReviewFindings(
val sessionId: SessionId,
val stageId: StageId,
val verdict: String,
val findings: List<ReviewFindingDto>,
val blocked: Boolean,
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
// -- Approval --
@Serializable
@@ -349,7 +349,10 @@ class DomainEventMapperTest {
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals(
ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt, diff = null, event.sequence, 0L),
ServerMessage.ToolCompleted(
sessionId, "file_write", "wrote 3 lines", occurredAt,
diff = null, affectedEntities = emptyList(), event.sequence, 0L,
),
result,
)
}
+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