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