| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "strings" |
| 8 | "unicode/utf8" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/recovery" |
| 12 | ) |
| 13 | |
| 14 | // ResolveRecovery applies a user decision on an Auto Guard card. |
| 15 | // action is continue|continue_task|revise. For revise, feedback is returned in the |
| 16 | // blocked tool result so the same agent sees it exactly once before retrying. |
| 17 | func (c *Controller) ResolveRecovery(id string, action agent.RecoveryAction, feedback string) error { |
| 18 | if c == nil { |
| 19 | return fmt.Errorf("controller is nil") |
| 20 | } |
| 21 | id = strings.TrimSpace(id) |
| 22 | if id == "" { |
| 23 | return fmt.Errorf("empty recovery approval id") |
| 24 | } |
| 25 | switch action { |
| 26 | case agent.RecoveryActionContinue, agent.RecoveryActionContinueTask, agent.RecoveryActionRevise: |
| 27 | default: |
| 28 | // Accept plain strings from wire clients. |
| 29 | switch strings.ToLower(strings.TrimSpace(string(action))) { |
| 30 | case "continue": |
| 31 | action = agent.RecoveryActionContinue |
| 32 | case "continue_task": |
| 33 | action = agent.RecoveryActionContinueTask |
| 34 | case "revise": |
| 35 | action = agent.RecoveryActionRevise |
| 36 | case "stop": |
| 37 | // Compatibility for older clients: cancel this proposed mutation. |
| 38 | // Whole-task cancellation remains on the app's ordinary Stop control. |
| 39 | action = agent.RecoveryActionRevise |
| 40 | if strings.TrimSpace(feedback) == "" { |
| 41 | feedback = "cancel this proposed action" |
| 42 | } |
| 43 | default: |
| 44 | return fmt.Errorf("unknown recovery action %q", action) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | c.mu.Lock() |
| 49 | gate := c.recoveryGate |
| 50 | c.mu.Unlock() |
| 51 | if gate == nil { |
| 52 | return fmt.Errorf("Auto Guard is not active") |
| 53 | } |
| 54 | // Host hard-caps free-text feedback; empty revise is filled by the gate. |
| 55 | // Clip on a UTF-8 boundary so multi-byte runes are never split. |
| 56 | feedback = clipUTF8(feedback, 4*1024) |
| 57 | // Validate and resolve the gate first. In particular, an unsupported |
| 58 | // continue_task must leave the live approval intact so the frontend can |
| 59 | // recover and offer a one-shot decision instead. |
| 60 | if err := gate.Resolve(id, recovery.Action(action), feedback); err != nil { |
| 61 | return err |
| 62 | } |
| 63 | |
| 64 | // Also resolve any matching approvalManager entry so legacy Approve paths |
| 65 | // and ReplayPending do not keep a stale prompt. |
| 66 | pending := c.approval.resolve(id) |
| 67 | if pending.reply != nil { |
| 68 | outcome := "recovery_revise" |
| 69 | switch action { |
| 70 | case agent.RecoveryActionContinue: |
| 71 | outcome = "recovery_continue" |
| 72 | case agent.RecoveryActionContinueTask: |
| 73 | outcome = "recovery_continue_task" |
| 74 | } |
| 75 | c.recordDecisionReceipt(pending, outcome) |
| 76 | switch action { |
| 77 | case agent.RecoveryActionContinue, agent.RecoveryActionContinueTask: |
| 78 | pending.reply <- approvalReply{allow: true} |
| 79 | default: |
| 80 | pending.reply <- approvalReply{allow: false} |
| 81 | } |
| 82 | } |
| 83 | return nil |
| 84 | } |
| 85 | |
| 86 | func clipUTF8(s string, n int) string { |
| 87 | s = strings.TrimSpace(s) |
| 88 | if n <= 0 || len(s) <= n { |
| 89 | return s |
| 90 | } |
| 91 | // Walk back to a rune start so the slice stays valid UTF-8. |
| 92 | for n > 0 && !utf8.RuneStart(s[n]) { |
| 93 | n-- |
| 94 | } |
| 95 | return s[:n] |
| 96 | } |
| 97 | |
| 98 | // initRecoveryGate constructs the shared recovery gate and attaches it to the |
| 99 | // executor. Called from New when recovery is available. |
| 100 | func (c *Controller) initRecoveryGate(reviewer recovery.Reviewer, headless bool) { |
| 101 | if c == nil || c.executor == nil { |
| 102 | return |
| 103 | } |
| 104 | gate := recovery.NewGate(recovery.Options{ |
| 105 | Headless: headless, |
| 106 | Mode: func() string { |
| 107 | return c.ToolApprovalMode() |
| 108 | }, |
| 109 | EmitPrompt: c.emitRecoveryPrompt, |
| 110 | Reviewer: reviewer, |
| 111 | PersistenceKey: c.SessionPath, |
| 112 | Persist: func(path string, snap recovery.Snapshot) { |
| 113 | c.persistRecoverySnapshot(path, snap) |
| 114 | }, |
| 115 | TaskSummary: func() string { |
| 116 | if c.executor == nil || c.executor.Session() == nil { |
| 117 | return "" |
| 118 | } |
| 119 | msgs := c.executor.Session().Snapshot() |
| 120 | for i := len(msgs) - 1; i >= 0; i-- { |
| 121 | if string(msgs[i].Role) == "user" && strings.TrimSpace(msgs[i].Content) != "" { |
| 122 | text := agent.UserMessageText(msgs[i]) |
| 123 | if len(text) > 800 { |
| 124 | return text[:800] + "…" |
| 125 | } |
| 126 | return text |
| 127 | } |
| 128 | } |
| 129 | return "" |
| 130 | }, |
| 131 | }) |
| 132 | c.mu.Lock() |
| 133 | c.recoveryGate = gate |
| 134 | c.mu.Unlock() |
| 135 | c.executor.SetRecoveryGate(gate) |
| 136 | } |
| 137 | |
| 138 | func (c *Controller) persistRecoverySnapshot(path string, snap recovery.Snapshot) { |
| 139 | if c == nil { |
| 140 | return |
| 141 | } |
| 142 | if strings.TrimSpace(path) == "" { |
| 143 | return |
| 144 | } |
| 145 | if err := recovery.SaveSnapshot(path, snap); err != nil { |
| 146 | slog.Warn("controller: recovery snapshot", "err", err) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // loadRecoveryState restores the recovery gate sidecar for a session path. |
| 151 | func (c *Controller) loadRecoveryState(path string) { |
| 152 | if c == nil { |
| 153 | return |
| 154 | } |
| 155 | c.approval.clearKind(recovery.ApprovalKindRecovery) |
| 156 | c.mu.Lock() |
| 157 | gate := c.recoveryGate |
| 158 | c.mu.Unlock() |
| 159 | if gate != nil { |
| 160 | snap := recovery.Snapshot{} |
| 161 | if strings.TrimSpace(path) != "" { |
| 162 | loaded, err := recovery.LoadSnapshot(path) |
| 163 | if err != nil { |
| 164 | slog.Warn("controller: load recovery snapshot", "err", err) |
| 165 | } else { |
| 166 | snap = loaded |
| 167 | } |
| 168 | } |
| 169 | // Missing, empty, or unreadable sidecars must still replace the old |
| 170 | // in-memory state; otherwise a session switch carries its checkpoint. |
| 171 | gate.Restore(snap) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // resetRecoveryForNewSession clears any failure checkpoint inherited from the |
| 176 | // previous path. Metadata is not created here: richer frontends still need to |
| 177 | // attach topic/scope ownership before the first sidecar write. |
| 178 | func (c *Controller) resetRecoveryForNewSession(path string) { |
| 179 | if c == nil { |
| 180 | return |
| 181 | } |
| 182 | c.loadRecoveryState(path) |
| 183 | } |
| 184 | |
| 185 | // carryRecoveryState moves a tip branch onto a new session identity without |
| 186 | // carrying live approval channels or task-local grants across the boundary. |
| 187 | func (c *Controller) carryRecoveryState(path string) { |
| 188 | if c == nil { |
| 189 | return |
| 190 | } |
| 191 | c.approval.clearKind(recovery.ApprovalKindRecovery) |
| 192 | c.mu.Lock() |
| 193 | gate := c.recoveryGate |
| 194 | c.mu.Unlock() |
| 195 | if gate == nil { |
| 196 | return |
| 197 | } |
| 198 | gate.Restore(gate.Snapshot()) |
| 199 | c.saveRecoveryState(path) |
| 200 | } |
| 201 | |
| 202 | // CarryRecoveryFrom moves prev's in-memory recovery checkpoint into c across |
| 203 | // a same-session controller rebuild (boot.Rebuild). Live approval channels |
| 204 | // never cross the boundary — pending recovery prompts are cleared, not |
| 205 | // transferred, matching the in-session carry above. Call it only when no |
| 206 | // persisted sidecar was restored (the outgoing controller never pinned a |
| 207 | // session path); a sidecar restored by Resume is the authoritative state. |
| 208 | func (c *Controller) CarryRecoveryFrom(prev *Controller) { |
| 209 | if c == nil || prev == nil { |
| 210 | return |
| 211 | } |
| 212 | c.approval.clearKind(recovery.ApprovalKindRecovery) |
| 213 | prev.mu.Lock() |
| 214 | prevGate := prev.recoveryGate |
| 215 | prev.mu.Unlock() |
| 216 | c.mu.Lock() |
| 217 | gate := c.recoveryGate |
| 218 | c.mu.Unlock() |
| 219 | if gate == nil || prevGate == nil { |
| 220 | return |
| 221 | } |
| 222 | gate.Restore(prevGate.Snapshot()) |
| 223 | } |
| 224 | |
| 225 | func (c *Controller) flushRecoveryPersistence(path string) { |
| 226 | if c == nil { |
| 227 | return |
| 228 | } |
| 229 | c.mu.Lock() |
| 230 | gate := c.recoveryGate |
| 231 | c.mu.Unlock() |
| 232 | if gate != nil { |
| 233 | gate.FlushPersistence(path) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // saveRecoveryState persists the recovery gate sidecar. The independent |
| 238 | // reviewer resets to its fixed system prompt before every review, so persisting |
| 239 | // its transient conversation adds no cache warmth and only creates a second |
| 240 | // transcript-shaped file beside the real session. |
| 241 | func (c *Controller) saveRecoveryState(path string) { |
| 242 | if c == nil || strings.TrimSpace(path) == "" { |
| 243 | return |
| 244 | } |
| 245 | c.mu.Lock() |
| 246 | gate := c.recoveryGate |
| 247 | c.mu.Unlock() |
| 248 | if gate != nil { |
| 249 | // Persist evidence-only projection; never write active Episode locks. |
| 250 | if err := recovery.SaveSnapshot(path, gate.PersistenceSnapshot()); err != nil { |
| 251 | slog.Warn("controller: recovery snapshot", "err", err) |
| 252 | } |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // RecoveryMetrics returns content-free recovery counters for export/observation. |
| 257 | func (c *Controller) RecoveryMetrics() recovery.Metrics { |
| 258 | if c == nil { |
| 259 | return recovery.Metrics{} |
| 260 | } |
| 261 | c.mu.Lock() |
| 262 | gate := c.recoveryGate |
| 263 | c.mu.Unlock() |
| 264 | if gate == nil { |
| 265 | return recovery.Metrics{} |
| 266 | } |
| 267 | return gate.Metrics() |
| 268 | } |
| 269 | |
| 270 | // DrainRecoveryMetrics returns only counters recorded since the previous |
| 271 | // drain. Desktop calls this once per completed turn to avoid re-emitting the |
| 272 | // controller's cumulative lifetime totals. |
| 273 | func (c *Controller) DrainRecoveryMetrics() recovery.Metrics { |
| 274 | if c == nil { |
| 275 | return recovery.Metrics{} |
| 276 | } |
| 277 | c.mu.Lock() |
| 278 | gate := c.recoveryGate |
| 279 | c.mu.Unlock() |
| 280 | if gate == nil { |
| 281 | return recovery.Metrics{} |
| 282 | } |
| 283 | return gate.DrainMetrics() |
| 284 | } |
| 285 | |
| 286 | // ReplayUnresolvedRecoveries is retained for frontend/API compatibility. |
| 287 | // Live prompts are replayed by the ordinary approval manager. After process |
| 288 | // death, the next proposed action is classified again instead of replaying a |
| 289 | // stale one-call authorization. |
| 290 | func (c *Controller) ReplayUnresolvedRecoveries() { |
| 291 | } |
| 292 | |
| 293 | func (c *Controller) emitRecoveryPrompt(ctx context.Context, taskID string, pending recovery.PendingProposal, failure *recovery.FailureEvent) (string, error) { |
| 294 | if c == nil { |
| 295 | return "", fmt.Errorf("controller is nil") |
| 296 | } |
| 297 | // Strict fresh decision: never session/persist grants, never auto-drain on |
| 298 | // mode switch. |
| 299 | c.approval.promptMu.Lock() |
| 300 | c.approval.promptEmitMu.Lock() |
| 301 | // Hold promptMu for the duration of registration+emit only; waiting happens |
| 302 | // in the recovery gate on its own channel. We deliberately do not block here |
| 303 | // on the approval reply — ResolveRecovery unblocks the gate. |
| 304 | ev := recovery.ToEventApproval("", pending, failure) |
| 305 | id, reply := c.approval.registerDecisionKind( |
| 306 | pending.Tool, |
| 307 | recoveryFirstNonEmpty(pending.Subject, pending.Tool), |
| 308 | recoveryFirstNonEmpty(pending.Rationale, "Auto Guard"), |
| 309 | true, |
| 310 | false, |
| 311 | recovery.ApprovalKindRecovery, |
| 312 | ev.Recovery, |
| 313 | ) |
| 314 | ev.ID = id |
| 315 | c.mu.Lock() |
| 316 | gate := c.recoveryGate |
| 317 | c.mu.Unlock() |
| 318 | if gate != nil { |
| 319 | // Bind before Emit: some sinks synchronously resolve the event from |
| 320 | // inside Emit, so binding afterwards loses that decision. |
| 321 | gate.BindApprovalID(taskID, id) |
| 322 | } |
| 323 | // Drain the ordinary approval reply when ResolveRecovery/Approve fires so |
| 324 | // the channel never leaks; the gate is the real waiter. |
| 325 | go func() { |
| 326 | select { |
| 327 | case <-reply: |
| 328 | case <-ctx.Done(): |
| 329 | c.approval.cancel(id) |
| 330 | } |
| 331 | }() |
| 332 | |
| 333 | c.sink.Emit(c.approvalRequestEvent(ev)) |
| 334 | c.approval.promptEmitMu.Unlock() |
| 335 | c.approval.promptMu.Unlock() |
| 336 | |
| 337 | if c.hooks != nil { |
| 338 | go c.hooks.Notification(ctx, "Auto Guard: confirm the next action", "permission_prompt") |
| 339 | } |
| 340 | return id, nil |
| 341 | } |
| 342 | |
| 343 | func recoveryFirstNonEmpty(vals ...string) string { |
| 344 | for _, v := range vals { |
| 345 | if strings.TrimSpace(v) != "" { |
| 346 | return strings.TrimSpace(v) |
| 347 | } |
| 348 | } |
| 349 | return "" |
| 350 | } |
| 351 | |
| 352 | // beginRecoveryEpisode opens a fresh host-owned Recovery Episode. Failure, |
| 353 | // reviewer, and stop budgets clear; explicit task grants survive. Safe to call |
| 354 | // when recovery is disabled. |
| 355 | func (c *Controller) beginRecoveryEpisode() { |
| 356 | if c == nil { |
| 357 | return |
| 358 | } |
| 359 | c.mu.Lock() |
| 360 | gate := c.recoveryGate |
| 361 | c.mu.Unlock() |
| 362 | if gate == nil { |
| 363 | return |
| 364 | } |
| 365 | if ctrl, ok := any(gate).(agent.RecoveryEpisodeControl); ok { |
| 366 | ctrl.BeginEpisode() |
| 367 | } |
| 368 | } |
| 369 |