返回 DeepSeek-Reasonix
recovery.go
根目录 / internal / control / recovery.go
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
369 lines GO