返回 DeepSeek-Reasonix
recovery_test.go
根目录 / internal / control / recovery_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/event"
14 "reasonix/internal/permission"
15 "reasonix/internal/provider"
16 "reasonix/internal/recovery"
17 "reasonix/internal/tool"
18 )
19
20 type recoveryWriteTool struct {
21 name string
22 readOnly bool
23 mu sync.Mutex
24 runs int
25 failOnce bool
26 failed bool
27 }
28
29 func (t *recoveryWriteTool) Name() string { return t.name }
30 func (t *recoveryWriteTool) Description() string { return "test tool" }
31 func (t *recoveryWriteTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
32 func (t *recoveryWriteTool) ReadOnly() bool { return t.readOnly }
33 func (t *recoveryWriteTool) Execute(context.Context, json.RawMessage) (string, error) {
34 t.mu.Lock()
35 defer t.mu.Unlock()
36 t.runs++
37 if t.failOnce && !t.failed {
38 t.failed = true
39 return "FAIL", errRecoveryTestFail
40 }
41 return "ok", nil
42 }
43
44 type recoveryTestFailError struct{}
45
46 func (recoveryTestFailError) Error() string { return "exit status 1" }
47
48 var errRecoveryTestFail = recoveryTestFailError{}
49
50 type controlRecoveryReviewerFunc func(context.Context, *recovery.FailureEvent, []string, recovery.Proposal, string) (recovery.ReviewVerdict, error)
51
52 func (f controlRecoveryReviewerFunc) Review(ctx context.Context, failure *recovery.FailureEvent, diagnosis []string, proposal recovery.Proposal, taskSummary string) (recovery.ReviewVerdict, error) {
53 return f(ctx, failure, diagnosis, proposal, taskSummary)
54 }
55
56 func TestRecoveryExecutionRiskDoesNotPrompt(t *testing.T) {
57 bash := &recoveryWriteTool{name: "bash", failOnce: true}
58 reg := tool.NewRegistry()
59 reg.Add(bash)
60
61 prov := &recordingProvider{streams: [][]provider.Chunk{
62 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "1", Name: "bash", Arguments: `{"command":"npx vitest run src/lib/foo.test.ts 2>&1 | tail -40"}`}}},
63 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "2", Name: "bash", Arguments: `{"command":"svn diff"}`}}},
64 {{Type: provider.ChunkText, Text: "done"}},
65 }}
66
67 sess := agent.NewSession("sys")
68 ag := agent.New(prov, reg, sess, agent.Options{MaxSteps: 6}, event.Discard)
69 c := New(Options{
70 Runner: ag,
71 Executor: ag,
72 Policy: permission.Policy{Mode: permission.Allow},
73 })
74 c.SetToolApprovalMode(ToolApprovalAuto)
75 c.EnableInteractiveApproval()
76
77 if err := c.Run(context.Background(), "test then fix"); err != nil {
78 t.Fatalf("Run: %v", err)
79 }
80
81 if bash.runs != 2 {
82 t.Fatalf("bash runs = %d, want failed npx verification plus automatic svn diff", bash.runs)
83 }
84 if got := c.RecoveryMetrics().HumanPrompts; got != 0 {
85 t.Fatalf("execution risk prompts = %d, want 0", got)
86 }
87 }
88
89 func TestRecoveryStaleEditCanReadAndRetryWithFreshAnchor(t *testing.T) {
90 edit := &recoveryWriteTool{name: "edit_file", failOnce: true}
91 read := &recoveryWriteTool{name: "read_file", readOnly: true}
92 reg := tool.NewRegistry()
93 reg.Add(edit)
94 reg.Add(read)
95
96 prov := &recordingProvider{streams: [][]provider.Chunk{
97 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
98 ID: "1", Name: "edit_file",
99 Arguments: `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`,
100 }}},
101 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
102 ID: "2", Name: "read_file", Arguments: `{"path":"prompt.txt"}`,
103 }}},
104 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
105 ID: "3", Name: "edit_file",
106 Arguments: `{"path":"prompt.txt","old_string":"current","new_string":"ready"}`,
107 }}},
108 {{Type: provider.ChunkText, Text: "done"}},
109 }}
110 ag := agent.New(prov, reg, agent.NewSession("sys"), agent.Options{MaxSteps: 8}, event.Discard)
111 c := New(Options{
112 Runner: ag, Executor: ag,
113 Policy: permission.Policy{Mode: permission.Allow},
114 })
115 c.SetToolApprovalMode(ToolApprovalAuto)
116 c.EnableInteractiveApproval()
117
118 if err := c.Run(context.Background(), "repair the stale edit"); err != nil {
119 t.Fatalf("Run: %v", err)
120 }
121 if edit.runs != 2 || read.runs != 1 {
122 t.Fatalf("tool runs edit=%d read=%d, want edit=2 read=1", edit.runs, read.runs)
123 }
124 if got := c.RecoveryMetrics().HumanPrompts; got != 0 {
125 t.Fatalf("stale-anchor recovery prompts = %d, want 0", got)
126 }
127 }
128
129 func TestRecoveryReviseBlocksPlanTransition(t *testing.T) {
130 ag := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
131 var c *Controller
132 c = New(Options{
133 Runner: ag, Executor: ag, Policy: permission.Policy{Mode: permission.Allow},
134 RecoveryReviewer: controlRecoveryReviewerFunc(func(context.Context, *recovery.FailureEvent, []string, recovery.Proposal, string) (recovery.ReviewVerdict, error) {
135 return recovery.ReviewVerdict{Outcome: recovery.ReviewConfirm, ChangeKind: recovery.ChangeStrategy, Rationale: "choose API direction"}, nil
136 }),
137 Sink: event.FuncSink(func(e event.Event) {
138 if e.Kind == event.ApprovalRequest && e.Approval.Kind == recovery.ApprovalKindRecovery {
139 _ = c.ResolveRecovery(e.Approval.ID, agent.RecoveryActionRevise, "keep the current API")
140 }
141 }),
142 })
143 c.SetToolApprovalMode(ToolApprovalAuto)
144 c.EnableInteractiveApproval()
145 c.mu.Lock()
146 gate := c.recoveryGate
147 c.mu.Unlock()
148 dec, err := gate.BeforeMutation(context.Background(), recovery.Proposal{
149 Tool: "todo_write", ReadOnly: true, PlanTransition: true,
150 PlanBefore: "1. Keep API [in_progress]", PlanAfter: "1. Replace API [in_progress]",
151 })
152 if err != nil || dec.Allow || !dec.Blocked || !strings.Contains(dec.Message, "keep the current API") {
153 t.Fatalf("plan revise = %+v, %v", dec, err)
154 }
155 }
156
157 func TestRecoveryInactiveUnderYolo(t *testing.T) {
158 bash := &recoveryWriteTool{name: "bash", failOnce: true}
159 write := &recoveryWriteTool{name: "write_file"}
160 reg := tool.NewRegistry()
161 reg.Add(bash)
162 reg.Add(write)
163 prov := &recordingProvider{streams: [][]provider.Chunk{
164 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "1", Name: "bash", Arguments: `{"command":"go test ./..."}`}}},
165 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "2", Name: "write_file", Arguments: `{"path":"a.go","content":"x"}`}}},
166 {{Type: provider.ChunkText, Text: "done"}},
167 }}
168 sess := agent.NewSession("sys")
169 ag := agent.New(prov, reg, sess, agent.Options{MaxSteps: 6}, event.Discard)
170 c := New(Options{
171 Runner: ag,
172 Executor: ag,
173 Policy: permission.Policy{Mode: permission.Allow},
174 })
175 c.SetToolApprovalMode(ToolApprovalYolo)
176 c.EnableInteractiveApproval()
177
178 if err := c.Run(context.Background(), "test then fix"); err != nil {
179 t.Fatalf("Run: %v", err)
180 }
181 if write.runs != 1 {
182 t.Fatalf("yolo should run write without recovery pause, runs=%d", write.runs)
183 }
184 c.mu.Lock()
185 gate := c.recoveryGate
186 c.mu.Unlock()
187 if gate != nil {
188 if st := gate.Snapshot().Tasks["root"]; st != nil && st.Failure != nil {
189 t.Fatalf("yolo must not arm recovery failure: %+v", st)
190 }
191 }
192 }
193
194 func TestRecoveryHeadlessDoesNotBlockExecutionRisk(t *testing.T) {
195 bash := &recoveryWriteTool{name: "bash", failOnce: true}
196 reg := tool.NewRegistry()
197 reg.Add(bash)
198 prov := &recordingProvider{streams: [][]provider.Chunk{
199 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "1", Name: "bash", Arguments: `{"command":"go test ./..."}`}}},
200 {{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "2", Name: "bash", Arguments: `{"command":"git push origin feature"}`}}},
201 {{Type: provider.ChunkText, Text: "reported blocker"}},
202 }}
203 sess := agent.NewSession("sys")
204 ag := agent.New(prov, reg, sess, agent.Options{MaxSteps: 6}, event.Discard)
205 c := New(Options{
206 Runner: ag,
207 Executor: ag,
208 Policy: permission.Policy{Mode: permission.Allow},
209 RecoveryHeadless: true,
210 })
211 c.SetToolApprovalMode(ToolApprovalAuto)
212
213 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
214 defer cancel()
215 if err := c.Run(ctx, "test then fix"); err != nil {
216 t.Fatalf("headless Run: %v", err)
217 }
218 if bash.runs != 2 {
219 t.Fatalf("headless Auto should leave push to permission policy, bash runs=%d", bash.runs)
220 }
221 if got := requestMessagesText(prov.requests[len(prov.requests)-1].Messages); strings.Contains(got, "no decision channel") {
222 t.Fatalf("execution risk unexpectedly produced headless plan blocker:\n%s", got)
223 }
224 }
225
226 func TestLegacyApproveResolvesWaiterOnlyPlanTransition(t *testing.T) {
227 // Old clients only call Approve. Normal-execution plan cards have a
228 // live waiter but no taskRuntime, so Snapshot cannot discover them.
229 ag := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
230 var c *Controller
231 var approvalID string
232 c = New(Options{
233 Runner: ag, Executor: ag,
234 Policy: permission.Policy{Mode: permission.Allow},
235 RecoveryReviewer: controlRecoveryReviewerFunc(func(context.Context, *recovery.FailureEvent, []string, recovery.Proposal, string) (recovery.ReviewVerdict, error) {
236 return recovery.ReviewVerdict{Outcome: recovery.ReviewConfirm, ChangeKind: recovery.ChangeStrategy, Rationale: "choose API direction"}, nil
237 }),
238 Sink: event.FuncSink(func(e event.Event) {
239 if e.Kind == event.ApprovalRequest && e.Approval.Kind == recovery.ApprovalKindRecovery {
240 approvalID = e.Approval.ID
241 // Simulate a legacy client that only knows Approve.
242 c.Approve(e.Approval.ID, true, true, true) // session/persist must be ignored
243 }
244 }),
245 })
246 c.SetToolApprovalMode(ToolApprovalAuto)
247 c.EnableInteractiveApproval()
248
249 c.mu.Lock()
250 gate := c.recoveryGate
251 c.mu.Unlock()
252 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
253 defer cancel()
254 dec, err := gate.BeforeMutation(ctx, recovery.Proposal{
255 Tool: "todo_write", ReadOnly: true, PlanTransition: true,
256 PlanBefore: "1. Keep API [in_progress]", PlanAfter: "1. Replace API [in_progress]",
257 })
258 if err != nil || !dec.Allow {
259 t.Fatalf("legacy Approve did not unblock plan card: %+v %v", dec, err)
260 }
261 if approvalID == "" {
262 t.Fatal("expected a recovery approval id to be emitted")
263 }
264 if gate.HasApproval(approvalID) {
265 t.Fatalf("HasApproval(%q) = true after legacy Approve, want false", approvalID)
266 }
267 }
268
269 func TestRecoveryPromptCanResolveSynchronouslyFromSink(t *testing.T) {
270 ag := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
271 var c *Controller
272 var resolveErr error
273 c = New(Options{
274 Runner: ag, Executor: ag,
275 Policy: permission.Policy{Mode: permission.Allow},
276 RecoveryReviewer: controlRecoveryReviewerFunc(func(context.Context, *recovery.FailureEvent, []string, recovery.Proposal, string) (recovery.ReviewVerdict, error) {
277 return recovery.ReviewVerdict{Outcome: recovery.ReviewConfirm, ChangeKind: recovery.ChangeScope, Rationale: "choose product scope"}, nil
278 }),
279 Sink: event.FuncSink(func(e event.Event) {
280 if e.Kind == event.ApprovalRequest && e.Approval.Kind == recovery.ApprovalKindRecovery {
281 resolveErr = c.ResolveRecovery(e.Approval.ID, agent.RecoveryActionContinue, "")
282 }
283 }),
284 })
285 c.SetToolApprovalMode(ToolApprovalAuto)
286 c.EnableInteractiveApproval()
287
288 c.mu.Lock()
289 gate := c.recoveryGate
290 c.mu.Unlock()
291 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
292 defer cancel()
293 dec, err := gate.BeforeMutation(ctx, recovery.Proposal{
294 Tool: "todo_write", ReadOnly: true, PlanTransition: true,
295 PlanBefore: "1. Current scope [in_progress]", PlanAfter: "1. Expanded scope [in_progress]",
296 })
297 if resolveErr != nil {
298 t.Fatalf("synchronous ResolveRecovery: %v", resolveErr)
299 }
300 if err != nil || !dec.Allow {
301 t.Fatalf("BeforeMutation = (%+v, %v), want synchronous continue", dec, err)
302 }
303 if st := gate.Snapshot().Tasks["root"]; st != nil && st.ApprovalID != "" {
304 t.Fatalf("resolved approval was re-created: %+v", st)
305 }
306 }
307
308 func TestSetFreshSessionPathClearsRecoveryState(t *testing.T) {
309 dir := t.TempDir()
310 oldPath := filepath.Join(dir, "old.jsonl")
311 newPath := filepath.Join(dir, "new.jsonl")
312 ag := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
313 c := New(Options{
314 Runner: ag, Executor: ag, SessionDir: dir, SessionPath: oldPath,
315 })
316 c.SetToolApprovalMode(ToolApprovalAuto)
317 c.mu.Lock()
318 gate := c.recoveryGate
319 c.mu.Unlock()
320 gate.ObserveResult(context.Background(), recovery.Observation{
321 Tool: "bash", Verification: true,
322 Args: json.RawMessage(`{"command":"go test ./..."}`), ErrSummary: "fail",
323 })
324 if st := gate.Snapshot().Tasks["root"]; st == nil || st.Failure == nil {
325 t.Fatal("test setup did not arm recovery")
326 }
327 c.SetFreshSessionPath(newPath)
328 if got := gate.Snapshot().Tasks; len(got) != 0 {
329 t.Fatalf("new session retained old recovery state: %+v", got)
330 }
331 // The async write scheduled above captured oldPath; it must not create a
332 // failing checkpoint beside the newly selected session. Wait through the
333 // gate instead of racing an atomic rename: Windows denies the read while
334 // antivirus/indexing filters still hold the destination during replacement.
335 gate.FlushPersistence(oldPath)
336 oldSnap, err := recovery.LoadSnapshot(oldPath)
337 if err != nil {
338 t.Fatalf("LoadSnapshot(old): %v", err)
339 }
340 if len(oldSnap.Tasks) == 0 {
341 t.Fatal("old-session recovery snapshot was not persisted")
342 }
343 newSnap, err := recovery.LoadSnapshot(newPath)
344 if err != nil {
345 t.Fatalf("LoadSnapshot(new): %v", err)
346 }
347 if len(newSnap.Tasks) != 0 {
348 t.Fatalf("old recovery snapshot landed on new session: %+v", newSnap.Tasks)
349 }
350 }
351
352 func TestFreshSessionRotationsClearRecoveryState(t *testing.T) {
353 for _, tc := range []struct {
354 name string
355 rotate func(*Controller) error
356 }{
357 {name: "new", rotate: func(c *Controller) error { return c.NewSession() }},
358 {name: "clear", rotate: func(c *Controller) error { return c.ClearSession() }},
359 } {
360 t.Run(tc.name, func(t *testing.T) {
361 dir := t.TempDir()
362 path := filepath.Join(dir, "old.jsonl")
363 sess := agent.NewSession("sys")
364 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
365 if err := sess.Save(path); err != nil {
366 t.Fatalf("Save session: %v", err)
367 }
368 ag := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
369 c := New(Options{Runner: ag, Executor: ag, SessionDir: dir, SessionPath: path})
370 defer c.Close()
371 c.SetToolApprovalMode(ToolApprovalAuto)
372 c.mu.Lock()
373 gate := c.recoveryGate
374 c.mu.Unlock()
375 gate.ObserveResult(context.Background(), recovery.Observation{
376 Tool: "bash", Verification: true,
377 Args: json.RawMessage(`{"command":"go test ./..."}`), ErrSummary: "fail",
378 })
379 if err := tc.rotate(c); err != nil {
380 t.Fatalf("rotate: %v", err)
381 }
382 if got := gate.Snapshot().Tasks; len(got) != 0 {
383 t.Fatalf("fresh session retained recovery state: %+v", got)
384 }
385 if c.SessionPath() == path {
386 t.Fatalf("session path did not rotate: %q", path)
387 }
388 })
389 }
390 }
391
392 func TestNewSessionWaitsForPendingRecoveryPersistence(t *testing.T) {
393 dir := t.TempDir()
394 path := filepath.Join(dir, "old.jsonl")
395 sess := agent.NewSession("sys")
396 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
397 if err := sess.Save(path); err != nil {
398 t.Fatalf("Save session: %v", err)
399 }
400 ag := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
401 c := New(Options{Runner: ag, Executor: ag, SessionDir: dir, SessionPath: path})
402 defer c.Close()
403 c.SetToolApprovalMode(ToolApprovalAuto)
404
405 started := make(chan struct{})
406 release := make(chan struct{})
407 var startOnce sync.Once
408 var releaseOnce sync.Once
409 t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
410 gate := recovery.NewGate(recovery.Options{
411 Mode: c.ToolApprovalMode,
412 PersistenceKey: c.SessionPath,
413 Persist: func(capturedPath string, snap recovery.Snapshot) {
414 startOnce.Do(func() { close(started) })
415 <-release
416 c.persistRecoverySnapshot(capturedPath, snap)
417 },
418 })
419 c.mu.Lock()
420 c.recoveryGate = gate
421 c.mu.Unlock()
422 ag.SetRecoveryGate(gate)
423
424 gate.ObserveResult(context.Background(), recovery.Observation{
425 Tool: "bash", Verification: true,
426 Args: json.RawMessage(`{"command":"go test ./..."}`), ErrSummary: "fail",
427 })
428 select {
429 case <-started:
430 case <-time.After(time.Second):
431 t.Fatal("recovery persistence did not start")
432 }
433
434 done := make(chan error, 1)
435 go func() { done <- c.NewSession() }()
436 select {
437 case err := <-done:
438 t.Fatalf("NewSession returned before old recovery persistence drained: %v", err)
439 case <-time.After(50 * time.Millisecond):
440 }
441
442 releaseOnce.Do(func() { close(release) })
443 select {
444 case err := <-done:
445 if err != nil {
446 t.Fatalf("NewSession: %v", err)
447 }
448 case <-time.After(time.Second):
449 t.Fatal("NewSession did not resume after recovery persistence drained")
450 }
451 }
452
452 lines GO