| 1 | package recovery |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "time" |
| 6 | |
| 7 | "reasonix/internal/agent" |
| 8 | "reasonix/internal/event" |
| 9 | ) |
| 10 | |
| 11 | // Phase is a derived view of recovery progress for compatibility snapshots. |
| 12 | // Runtime truth is "has failure" and "has waiter", not a stored phase enum. |
| 13 | type Phase string |
| 14 | |
| 15 | const ( |
| 16 | PhaseIdle Phase = "idle" |
| 17 | PhaseDiagnosing Phase = "diagnosing" |
| 18 | PhaseAwaitingDecision Phase = "awaiting_decision" |
| 19 | ) |
| 20 | |
| 21 | // ChangeKind classifies how the proposed recovery action differs from the |
| 22 | // original approach. |
| 23 | type ChangeKind string |
| 24 | |
| 25 | const ( |
| 26 | ChangeSameStrategy ChangeKind = "same_strategy" |
| 27 | ChangeStrategy ChangeKind = "strategy" |
| 28 | ChangeScope ChangeKind = "scope" |
| 29 | ChangeRisk ChangeKind = "risk" |
| 30 | ChangeUncertain ChangeKind = "uncertain" |
| 31 | ) |
| 32 | |
| 33 | // ReviewOutcome is the independent recovery reviewer's decision. |
| 34 | type ReviewOutcome string |
| 35 | |
| 36 | const ( |
| 37 | ReviewContinue ReviewOutcome = "continue" |
| 38 | ReviewConfirm ReviewOutcome = "confirm" |
| 39 | ) |
| 40 | |
| 41 | // FailureClass separates execution reliability from permission and product |
| 42 | // decisions. Permission/sandbox/user blocks never become FailureEvents. |
| 43 | type FailureClass string |
| 44 | |
| 45 | const ( |
| 46 | FailureClassExecution FailureClass = "execution" |
| 47 | FailureClassMutation FailureClass = "mutation" |
| 48 | FailureClassTransient FailureClass = "transient" |
| 49 | FailureClassVerification FailureClass = "verification" |
| 50 | ) |
| 51 | |
| 52 | // ReviewVerdict is the strict JSON shape the recovery reviewer must produce. |
| 53 | // Host already knows failure/diagnosis/proposed action; only outcome fields are |
| 54 | // required. Extra fields from older models are tolerated on parse. |
| 55 | type ReviewVerdict struct { |
| 56 | Outcome ReviewOutcome `json:"outcome"` |
| 57 | ChangeKind ChangeKind `json:"change_kind"` |
| 58 | Rationale string `json:"rationale"` |
| 59 | |
| 60 | // Legacy optional fields kept for older model outputs and tests. |
| 61 | FailureSummary string `json:"failure_summary,omitempty"` |
| 62 | Diagnosis string `json:"diagnosis,omitempty"` |
| 63 | ProposedAction string `json:"proposed_action,omitempty"` |
| 64 | } |
| 65 | |
| 66 | // FailureEvent records failure evidence for diagnosis and reviewer context. |
| 67 | // SafeRetryLeft/RepeatCount/DiagnosisNotes remain on the wire for old |
| 68 | // snapshots; runtime budgets live on taskRuntime. |
| 69 | type FailureEvent struct { |
| 70 | Class FailureClass `json:"class,omitempty"` |
| 71 | Tool string `json:"tool"` |
| 72 | ArgsSummary string `json:"args_summary,omitempty"` |
| 73 | Subject string `json:"subject,omitempty"` |
| 74 | ErrSummary string `json:"err_summary,omitempty"` |
| 75 | OutputExcerpt string `json:"output_excerpt,omitempty"` |
| 76 | SourceAgent string `json:"source_agent,omitempty"` |
| 77 | TaskID string `json:"task_id,omitempty"` |
| 78 | // TaskScopeID persists only stable goal scopes. Ordinary turn scopes are |
| 79 | // runtime-local and intentionally omitted so a restart cannot revive a stale |
| 80 | // technical latch for a new user turn. It never acts as the Episode budget key. |
| 81 | TaskScopeID string `json:"task_scope_id,omitempty"` |
| 82 | ReadOnly bool `json:"read_only,omitempty"` |
| 83 | Verification bool `json:"verification,omitempty"` |
| 84 | Mutates bool `json:"mutates,omitempty"` |
| 85 | RepeatCount int `json:"repeat_count,omitempty"` |
| 86 | CreatedAt time.Time `json:"created_at,omitempty"` |
| 87 | Args json.RawMessage `json:"args,omitempty"` |
| 88 | Fingerprint string `json:"fingerprint,omitempty"` |
| 89 | SafeRetryLeft int `json:"safe_retry_left,omitempty"` |
| 90 | DiagnosisNotes []string `json:"diagnosis_notes,omitempty"` |
| 91 | } |
| 92 | |
| 93 | // PendingProposal is the mutation paused for user confirmation. |
| 94 | // It is held only in the temporary waiter table, never as durable task state. |
| 95 | type PendingProposal struct { |
| 96 | Tool string `json:"tool"` |
| 97 | Subject string `json:"subject,omitempty"` |
| 98 | Preview string `json:"preview,omitempty"` |
| 99 | Args json.RawMessage `json:"args,omitempty"` |
| 100 | Fingerprint string `json:"fingerprint,omitempty"` |
| 101 | SourceAgent string `json:"source_agent,omitempty"` |
| 102 | ChangeKind ChangeKind `json:"change_kind,omitempty"` |
| 103 | Rationale string `json:"rationale,omitempty"` |
| 104 | Diagnosis string `json:"diagnosis,omitempty"` |
| 105 | Failure string `json:"failure,omitempty"` |
| 106 | Proposed string `json:"proposed,omitempty"` |
| 107 | PlanBefore string `json:"plan_before,omitempty"` |
| 108 | PlanAfter string `json:"plan_after,omitempty"` |
| 109 | // TaskGrant fields are transient host-classified scope. They are deliberately |
| 110 | // omitted from snapshots and never supplied by the model or wire client. |
| 111 | TaskGrantKey string `json:"-"` |
| 112 | TaskGrantTaskScope string `json:"-"` |
| 113 | TaskGrantDisplay string `json:"-"` |
| 114 | } |
| 115 | |
| 116 | // TaskState is the persistable / debug view of one task's recovery state. |
| 117 | // Runtime truth is taskRuntime; Snapshot/Restore project to and from this shape. |
| 118 | // |
| 119 | // Persistence projection writes only LastFailure as historical evidence. |
| 120 | // Failure / ConsecutiveFails / ReviewBlocks may still appear in live Snapshot() |
| 121 | // for debugging, and old on-disk values are migrated to evidence without re-arming. |
| 122 | type TaskState struct { |
| 123 | Phase Phase `json:"phase"` |
| 124 | Failure *FailureEvent `json:"failure,omitempty"` |
| 125 | LastFailure *FailureEvent `json:"last_failure,omitempty"` |
| 126 | Pending *PendingProposal `json:"pending,omitempty"` |
| 127 | ApprovalID string `json:"approval_id,omitempty"` |
| 128 | ConsecutiveFails int `json:"consecutive_fails,omitempty"` |
| 129 | ReviewBlocks int `json:"review_blocks,omitempty"` |
| 130 | TailInjected bool `json:"tail_injected,omitempty"` |
| 131 | // EpisodeID is runtime/debug only and never written by persistence projection. |
| 132 | EpisodeID string `json:"episode_id,omitempty"` |
| 133 | // EpisodeStopped / StopReason are live debug views only. |
| 134 | EpisodeStopped bool `json:"episode_stopped,omitempty"` |
| 135 | StopReason string `json:"stop_reason,omitempty"` |
| 136 | } |
| 137 | |
| 138 | // Snapshot is the form of all task recovery state. |
| 139 | // Live Snapshot() includes debug fields; PersistenceSnapshot() strips temporary |
| 140 | // lock/budget state so disk never re-arms Auto blocks after restart. |
| 141 | type Snapshot struct { |
| 142 | Tasks map[string]*TaskState `json:"tasks,omitempty"` |
| 143 | } |
| 144 | |
| 145 | // Metrics are content-free counters for release observation. |
| 146 | // They never record parameters, paths, or error bodies. |
| 147 | type Metrics struct { |
| 148 | FailureEvents int64 |
| 149 | RuleContinues int64 |
| 150 | ReviewContinues int64 |
| 151 | HumanPrompts int64 |
| 152 | HumanContinues int64 |
| 153 | TaskGrantContinues int64 |
| 154 | TaskGrantUses int64 |
| 155 | HumanRevises int64 |
| 156 | ReviewErrors int64 |
| 157 | ReviewLatencyMsSum int64 |
| 158 | ReviewLatencyCount int64 |
| 159 | RepeatPrompts int64 |
| 160 | |
| 161 | // Episode / generation counters (content-free). |
| 162 | OperationStops int64 |
| 163 | EpisodeFailureStops int64 |
| 164 | ReviewStops int64 |
| 165 | StoppedOpRetryStops int64 |
| 166 | ModeResets int64 |
| 167 | EpisodeRotations int64 |
| 168 | StaleObservationsIgnored int64 |
| 169 | } |
| 170 | |
| 171 | // ApprovalKindRecovery is the Approval.Kind value for recovery cards. |
| 172 | const ApprovalKindRecovery = "recovery" |
| 173 | |
| 174 | // ApprovalKindTool and ApprovalKindPlan keep ordinary approval kinds explicit. |
| 175 | const ( |
| 176 | ApprovalKindTool = "tool" |
| 177 | ApprovalKindPlan = "plan" |
| 178 | ) |
| 179 | |
| 180 | // ToEventApproval builds the event payload for a recovery confirmation card. |
| 181 | func ToEventApproval(id string, pending PendingProposal, failure *FailureEvent) event.Approval { |
| 182 | rec := &event.RecoveryApproval{ |
| 183 | SourceAgent: pending.SourceAgent, |
| 184 | FailedTool: "", |
| 185 | FailedSummary: pending.Failure, |
| 186 | Diagnosis: pending.Diagnosis, |
| 187 | NextTool: pending.Tool, |
| 188 | NextAction: firstNonEmpty(pending.Proposed, pending.Subject, pending.Preview), |
| 189 | ChangeKind: string(pending.ChangeKind), |
| 190 | ChangeRationale: pending.Rationale, |
| 191 | ReviewRationale: pending.Rationale, |
| 192 | PlanBefore: pending.PlanBefore, |
| 193 | PlanAfter: pending.PlanAfter, |
| 194 | CanGrantTask: pending.TaskGrantKey != "", |
| 195 | TaskGrantScope: pending.TaskGrantDisplay, |
| 196 | } |
| 197 | if failure != nil { |
| 198 | rec.FailedTool = failure.Tool |
| 199 | if rec.FailedSummary == "" { |
| 200 | rec.FailedSummary = failure.ErrSummary |
| 201 | } |
| 202 | if rec.SourceAgent == "" { |
| 203 | rec.SourceAgent = failure.SourceAgent |
| 204 | } |
| 205 | } |
| 206 | subject := firstNonEmpty(pending.Subject, pending.Preview, pending.Tool) |
| 207 | reason := firstNonEmpty(pending.Rationale, pending.Diagnosis, "Plan change requires confirmation") |
| 208 | return event.Approval{ |
| 209 | ID: id, |
| 210 | Tool: pending.Tool, |
| 211 | Subject: subject, |
| 212 | Reason: reason, |
| 213 | Fresh: true, |
| 214 | Kind: ApprovalKindRecovery, |
| 215 | Recovery: rec, |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // Observation aliases keep call sites readable when bridging agent types. |
| 220 | type Observation = agent.RecoveryObservation |
| 221 | type Proposal = agent.RecoveryProposal |
| 222 | type Decision = agent.RecoveryDecision |
| 223 | type Action = agent.RecoveryAction |
| 224 | |
| 225 | const ( |
| 226 | ActionContinue = agent.RecoveryActionContinue |
| 227 | ActionContinueTask = agent.RecoveryActionContinueTask |
| 228 | ActionRevise = agent.RecoveryActionRevise |
| 229 | ) |
| 230 | |
| 231 | // DefaultReviseFeedback is injected when the user chooses "try another approach" |
| 232 | // without optional free-text feedback. |
| 233 | const DefaultReviseFeedback = "The pending mutation was rejected. Do not retry the same action. Summarize the failure cause, narrow the scope, and propose a safer alternative before attempting another mutation." |
| 234 | |
| 235 | func firstNonEmpty(vals ...string) string { |
| 236 | for _, v := range vals { |
| 237 | if v != "" { |
| 238 | return v |
| 239 | } |
| 240 | } |
| 241 | return "" |
| 242 | } |
| 243 |