返回 DeepSeek-Reasonix
decision_test.go
根目录 / internal / recovery / decision_test.go
1 package recovery
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync/atomic"
10 "testing"
11 "time"
12 )
13
14 // TestDecisionMatrix freezes the pure Auto Guard routing table.
15 // Each row is a product scenario from the PR plan decision matrix.
16 func TestDecisionMatrix(t *testing.T) {
17 tests := []struct {
18 name string
19 f Facts
20 want DecisionResult
21 }{
22 {
23 name: "ask mode bypasses auto guard",
24 f: Facts{AutoMode: false, Mutates: true},
25 want: DecisionResult{Route: RouteBypass},
26 },
27 {
28 name: "yolo mode bypasses auto guard",
29 f: Facts{AutoMode: false, Mutates: true, HighRisk: true},
30 want: DecisionResult{Route: RouteBypass},
31 },
32 {
33 name: "ordinary read allows without review",
34 f: Facts{AutoMode: true, ReadOnly: true},
35 want: DecisionResult{Route: RouteAllow},
36 },
37 {
38 name: "ordinary search allows without review",
39 f: Facts{AutoMode: true, ReadOnly: true, Mutates: false},
40 want: DecisionResult{Route: RouteAllow},
41 },
42 {
43 name: "ordinary mutation without failure allows without review",
44 f: Facts{AutoMode: true, Mutates: true},
45 want: DecisionResult{Route: RouteAllow},
46 },
47 {
48 name: "execution risk without failure stays on permission path",
49 f: Facts{AutoMode: true, Mutates: true, HighRisk: true},
50 want: DecisionResult{Route: RouteAllow},
51 },
52 {
53 name: "same failed operation still uses bounded recovery review",
54 f: Facts{
55 AutoMode: true, Mutates: true, HighRisk: true,
56 HasActiveFailure: true, SameFailedOperation: true, FailureCount: 1,
57 },
58 want: DecisionResult{Route: RouteReview},
59 },
60 {
61 name: "structured plan transition goes to reviewer without failure",
62 f: Facts{AutoMode: true, ReadOnly: true, PlanTransition: true},
63 want: DecisionResult{Route: RouteReview},
64 },
65 {
66 name: "first safe verification retry allows and consumes budget",
67 f: Facts{
68 AutoMode: true, Verification: true,
69 HasActiveFailure: true, FailureCount: 1, SafeRetryAvailable: true,
70 },
71 want: DecisionResult{Route: RouteAllow, ConsumeSafeRetry: true},
72 },
73 {
74 name: "different scoped operation after failure stays automatic",
75 f: Facts{
76 AutoMode: true, Mutates: true,
77 HasActiveFailure: true, FailureCount: 1, ExpandedScope: true,
78 },
79 want: DecisionResult{Route: RouteAllow},
80 },
81 {
82 name: "different strategy operation after failure stays automatic",
83 f: Facts{
84 AutoMode: true, Mutates: true,
85 HasActiveFailure: true, FailureCount: 1, StrategyChanged: true,
86 },
87 want: DecisionResult{Route: RouteAllow},
88 },
89 {
90 name: "second attempt of failed operation uses bounded recovery review",
91 f: Facts{
92 AutoMode: true, Mutates: true,
93 HasActiveFailure: true, SameFailedOperation: true, FailureCount: 2,
94 },
95 want: DecisionResult{Route: RouteReview},
96 },
97 {
98 name: "third repeat of the same operation stops",
99 f: Facts{
100 AutoMode: true, Mutates: true,
101 HasActiveFailure: true, FailureCount: 3, SameFailedOperation: true,
102 },
103 want: DecisionResult{Route: RouteStop, StopReason: StopReasonOperationFailures},
104 },
105 {
106 name: "different operation after three failures stays recoverable",
107 f: Facts{
108 AutoMode: true, Mutates: true,
109 HasActiveFailure: true, FailureCount: 3,
110 },
111 want: DecisionResult{Route: RouteAllow},
112 },
113 {
114 name: "ambiguous retry of the failed operation goes to reviewer",
115 f: Facts{
116 AutoMode: true, Mutates: true,
117 HasActiveFailure: true, SameFailedOperation: true, FailureCount: 1,
118 },
119 want: DecisionResult{Route: RouteReview},
120 },
121 {
122 name: "episode stop preserves read only diagnosis",
123 f: Facts{
124 AutoMode: true, ReadOnly: true, EpisodeStopped: true,
125 StopReason: StopReasonEpisodeFailures,
126 },
127 want: DecisionResult{Route: RouteAllow},
128 },
129 {
130 name: "safe retry budget does not apply when scope expands",
131 f: Facts{
132 AutoMode: true, Mutates: true, Verification: true,
133 HasActiveFailure: true, FailureCount: 1,
134 SafeRetryAvailable: true, ExpandedScope: true,
135 },
136 // Safe retry is evaluated before the bounded-failure/reviewer path.
137 // A true safe verification retry cannot also expand scope (classifier
138 // clears SafeRetryAvailable). When both flags appear, safe retry wins
139 // only if the host marked it available — the classifier must not.
140 want: DecisionResult{Route: RouteAllow, ConsumeSafeRetry: true},
141 },
142 }
143 for _, tt := range tests {
144 t.Run(tt.name, func(t *testing.T) {
145 got := Decide(tt.f)
146 if got != tt.want {
147 t.Fatalf("Decide = %+v, want %+v", got, tt.want)
148 }
149 })
150 }
151 }
152
153 func TestToEventApprovalCarriesPlanTransitionForDecisionSurfaces(t *testing.T) {
154 approval := ToEventApproval("plan-1", PendingProposal{
155 Tool: "todo_write",
156 Subject: "Update the active execution plan",
157 ChangeKind: ChangeScope,
158 Rationale: "choose the public API direction",
159 PlanBefore: "1. Keep API [in_progress]",
160 PlanAfter: "1. Replace API [in_progress]",
161 }, nil)
162 if approval.Recovery == nil {
163 t.Fatal("plan transition missing recovery payload")
164 }
165 if approval.Recovery.PlanBefore != "1. Keep API [in_progress]" || approval.Recovery.PlanAfter != "1. Replace API [in_progress]" {
166 t.Fatalf("plan transition payload = %+v", approval.Recovery)
167 }
168 }
169
170 func TestRepeatedFailureStopMessageDoesNotAskForRiskApproval(t *testing.T) {
171 got := repeatedFailureStopMessage(3, Proposal{Tool: "bash", Subject: "go test ./..."})
172 if !strings.Contains(got, "after 3") || !strings.Contains(got, "go test ./...") || !strings.Contains(got, "other operations remain available") {
173 t.Fatalf("stop message = %q", got)
174 }
175 }
176
177 // TestBehaviorMatrixGolden freezes end-to-end Gate outcomes for the product matrix.
178 // Ordinary paths must not call the reviewer; ambiguous recovery must.
179 func TestBehaviorMatrixGolden(t *testing.T) {
180 type outcome struct {
181 allow bool
182 prompted bool
183 reviews int32
184 blocked bool
185 }
186 run := func(t *testing.T, setup func(g *Gate), proposal Proposal, reviewer Reviewer) outcome {
187 t.Helper()
188 var reviews atomic.Int32
189 var prompted atomic.Bool
190 r := reviewer
191 if r == nil {
192 r = reviewerFunc(func(context.Context, *FailureEvent, []string, Proposal, string) (ReviewVerdict, error) {
193 reviews.Add(1)
194 return ReviewVerdict{Outcome: ReviewContinue, ChangeKind: ChangeSameStrategy}, nil
195 })
196 } else {
197 inner := r
198 r = reviewerFunc(func(ctx context.Context, f *FailureEvent, d []string, p Proposal, s string) (ReviewVerdict, error) {
199 reviews.Add(1)
200 return inner.Review(ctx, f, d, p, s)
201 })
202 }
203 g := NewGate(Options{Mode: func() string { return "auto" }, Reviewer: r})
204 g.opts.EmitPrompt = func(_ context.Context, taskID string, _ PendingProposal, _ *FailureEvent) (string, error) {
205 prompted.Store(true)
206 id := "a1"
207 g.BindApprovalID(taskID, id)
208 if err := g.Resolve(id, ActionContinue, ""); err != nil {
209 t.Fatalf("resolve: %v", err)
210 }
211 return id, nil
212 }
213 if setup != nil {
214 setup(g)
215 }
216 dec, err := g.BeforeMutation(context.Background(), proposal)
217 if err != nil {
218 t.Fatalf("BeforeMutation: %v", err)
219 }
220 return outcome{allow: dec.Allow, prompted: prompted.Load(), reviews: reviews.Load(), blocked: dec.Blocked}
221 }
222
223 t.Run("ordinary read zero reviews zero clicks", func(t *testing.T) {
224 got := run(t, nil, Proposal{Tool: "read_file", ReadOnly: true, Args: json.RawMessage(`{"path":"a.go"}`)}, nil)
225 if !got.allow || got.prompted || got.reviews != 0 {
226 t.Fatalf("got %+v", got)
227 }
228 })
229 t.Run("ordinary mutation without failure zero reviews", func(t *testing.T) {
230 got := run(t, nil, Proposal{
231 Tool: "write_file", Mutates: true, Subject: "a.go",
232 Args: json.RawMessage(`{"path":"a.go","content":"x"}`),
233 }, nil)
234 if !got.allow || got.prompted || got.reviews != 0 {
235 t.Fatalf("got %+v", got)
236 }
237 })
238 t.Run("execution safety boundary stays on permission path", func(t *testing.T) {
239 got := run(t, nil, Proposal{
240 Tool: "bash", Mutates: true, Subject: "git push origin feature",
241 Args: json.RawMessage(`{"command":"git push origin feature"}`),
242 }, nil)
243 if !got.allow || got.prompted || got.reviews != 0 {
244 t.Fatalf("got %+v", got)
245 }
246 })
247 t.Run("failure then read-only diagnosis allows without review", func(t *testing.T) {
248 got := run(t, func(g *Gate) {
249 g.ObserveResult(context.Background(), Observation{
250 Tool: "bash", Verification: true, ErrSummary: "fail",
251 Args: json.RawMessage(`{"command":"go test ./..."}`),
252 })
253 }, Proposal{Tool: "read_file", ReadOnly: true, Args: json.RawMessage(`{"path":"a.go"}`)}, nil)
254 if !got.allow || got.prompted || got.reviews != 0 {
255 t.Fatalf("got %+v", got)
256 }
257 })
258 t.Run("first safe verification retry allows without review", func(t *testing.T) {
259 args := json.RawMessage(`{"command":"go test ./..."}`)
260 got := run(t, func(g *Gate) {
261 g.ObserveResult(context.Background(), Observation{
262 Tool: "bash", Subject: "go test ./...", Verification: true, Args: args, ErrSummary: "fail",
263 })
264 }, Proposal{Tool: "bash", Subject: "go test ./...", Verification: true, Args: args}, nil)
265 if !got.allow || got.prompted || got.reviews != 0 {
266 t.Fatalf("got %+v", got)
267 }
268 })
269 t.Run("different scoped operation bypasses recovery reviewer", func(t *testing.T) {
270 got := run(t, func(g *Gate) {
271 g.ObserveResult(context.Background(), Observation{
272 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail",
273 Args: json.RawMessage(`{"path":"a.go"}`),
274 })
275 }, Proposal{
276 Tool: "write_file", Mutates: true, Subject: "b.go", ExpandedScope: true,
277 Args: json.RawMessage(`{"path":"b.go","content":"x"}`),
278 }, nil)
279 if !got.allow || got.prompted || got.reviews != 0 {
280 t.Fatalf("got %+v", got)
281 }
282 })
283 t.Run("different strategy operation bypasses recovery reviewer", func(t *testing.T) {
284 got := run(t, func(g *Gate) {
285 g.ObserveResult(context.Background(), Observation{
286 Tool: "bash", Verification: true, ErrSummary: "fail",
287 Args: json.RawMessage(`{"command":"go test"}`),
288 })
289 }, Proposal{
290 Tool: "write_file", Mutates: true, StrategyChanged: true,
291 Args: json.RawMessage(`{"path":"a.go","content":"x"}`),
292 }, nil)
293 if !got.allow || got.prompted || got.reviews != 0 {
294 t.Fatalf("got %+v", got)
295 }
296 })
297 t.Run("second recovery failure uses reviewer without prompting", func(t *testing.T) {
298 got := run(t, func(g *Gate) {
299 g.ObserveResult(context.Background(), Observation{
300 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail1",
301 Args: json.RawMessage(`{"path":"a.go"}`),
302 })
303 g.ObserveResult(context.Background(), Observation{
304 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail2",
305 Args: json.RawMessage(`{"path":"a.go"}`),
306 })
307 }, Proposal{
308 Tool: "write_file", Mutates: true, Subject: "a.go",
309 Args: json.RawMessage(`{"path":"a.go"}`),
310 }, nil)
311 if !got.allow || got.prompted || got.reviews != 1 {
312 t.Fatalf("got %+v", got)
313 }
314 })
315 t.Run("third repeated operation stops without prompting", func(t *testing.T) {
316 got := run(t, func(g *Gate) {
317 for i := 0; i < 3; i++ {
318 g.ObserveResult(context.Background(), Observation{
319 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail",
320 Args: json.RawMessage(`{"path":"a.go","content":"x"}`),
321 })
322 }
323 }, Proposal{
324 Tool: "write_file", Mutates: true, Subject: "a.go",
325 Args: json.RawMessage(`{"path":"a.go","content":"x"}`),
326 }, nil)
327 if got.allow || got.prompted || got.reviews != 0 || !got.blocked {
328 t.Fatalf("got %+v", got)
329 }
330 })
331 t.Run("different edit after three verification failures stays automatic", func(t *testing.T) {
332 got := run(t, func(g *Gate) {
333 for i := 0; i < 3; i++ {
334 g.ObserveResult(context.Background(), Observation{
335 Tool: "bash", Verification: true, Subject: "go test ./...", ErrSummary: "fail",
336 Args: json.RawMessage(`{"command":"go test ./..."}`),
337 })
338 }
339 }, Proposal{
340 Tool: "write_file", Mutates: true, Subject: "a.go",
341 Args: json.RawMessage(`{"path":"a.go","content":"fix"}`),
342 }, nil)
343 if !got.allow || got.prompted || got.reviews != 0 || got.blocked {
344 t.Fatalf("got %+v", got)
345 }
346 })
347 t.Run("ambiguous recovery calls reviewer once", func(t *testing.T) {
348 got := run(t, func(g *Gate) {
349 g.ObserveResult(context.Background(), Observation{
350 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail",
351 Args: json.RawMessage(`{"path":"a.go"}`),
352 })
353 }, Proposal{
354 Tool: "write_file", Mutates: true, Subject: "a.go",
355 Args: json.RawMessage(`{"path":"a.go"}`),
356 }, nil)
357 if !got.allow || got.prompted || got.reviews != 1 {
358 t.Fatalf("got %+v", got)
359 }
360 })
361 t.Run("reviewer same_strategy continues without prompt", func(t *testing.T) {
362 got := run(t, func(g *Gate) {
363 g.ObserveResult(context.Background(), Observation{
364 Tool: "bash", Verification: true, ErrSummary: "fail",
365 Args: json.RawMessage(`{"command":"go test"}`),
366 })
367 }, Proposal{
368 Tool: "write_file", Mutates: true, Subject: "a.go",
369 Args: json.RawMessage(`{"path":"a.go","content":"x"}`),
370 }, staticReviewer{ReviewVerdict{Outcome: ReviewContinue, ChangeKind: ChangeSameStrategy}})
371 if !got.allow || got.prompted || got.reviews != 0 {
372 t.Fatalf("got %+v", got)
373 }
374 })
375 t.Run("non plan recovery never opens a human confirmation", func(t *testing.T) {
376 for _, kind := range []ChangeKind{ChangeStrategy, ChangeScope} {
377 t.Run(string(kind), func(t *testing.T) {
378 args := json.RawMessage(`{"path":"a.go","content":"x"}`)
379 got := run(t, func(g *Gate) {
380 g.ObserveResult(context.Background(), Observation{
381 Tool: "write_file", Mutates: true, Subject: "a.go",
382 ErrSummary: "fail", Args: args,
383 })
384 }, Proposal{
385 Tool: "write_file", Mutates: true, Subject: "a.go", Args: args,
386 }, staticReviewer{ReviewVerdict{
387 Outcome: ReviewConfirm, ChangeKind: kind, Rationale: "the task now needs a user-owned plan choice",
388 }})
389 if got.allow || got.prompted || got.reviews != 1 || !got.blocked {
390 t.Fatalf("got %+v", got)
391 }
392 })
393 }
394 })
395 t.Run("normal execution plan transition prompts immediately", func(t *testing.T) {
396 got := run(t, nil, Proposal{
397 Tool: "todo_write", ReadOnly: true, PlanTransition: true,
398 PlanBefore: "1. Keep current API [in_progress]",
399 PlanAfter: "1. Replace the public API [in_progress]",
400 }, staticReviewer{ReviewVerdict{
401 Outcome: ReviewConfirm, ChangeKind: ChangeStrategy, Rationale: "public API direction belongs to the user",
402 }})
403 if !got.allow || !got.prompted || got.reviews != 1 || got.blocked {
404 t.Fatalf("got %+v", got)
405 }
406 })
407 t.Run("reviewer reject returns to agent without prompt", func(t *testing.T) {
408 args := json.RawMessage(`{"path":"a.go","content":"x"}`)
409 got := run(t, func(g *Gate) {
410 g.ObserveResult(context.Background(), Observation{
411 Tool: "write_file", Mutates: true, Subject: "a.go",
412 ErrSummary: "fail", Args: args,
413 })
414 }, Proposal{
415 Tool: "write_file", Mutates: true, Subject: "a.go", Args: args,
416 }, staticReviewer{ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeUncertain, Rationale: "not proven"}})
417 if got.allow || !got.blocked || got.prompted || got.reviews != 1 {
418 t.Fatalf("got %+v", got)
419 }
420 })
421 t.Run("reviewer third reject stops without prompt", func(t *testing.T) {
422 var reviews atomic.Int32
423 var prompts int
424 g := NewGate(Options{
425 Reviewer: reviewerFunc(func(context.Context, *FailureEvent, []string, Proposal, string) (ReviewVerdict, error) {
426 reviews.Add(1)
427 return ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeUncertain, Rationale: "no"}, nil
428 }),
429 })
430 args := json.RawMessage(`{"path":"a.go"}`)
431 g.ObserveResult(context.Background(), Observation{
432 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail", Args: args,
433 })
434 g.opts.EmitPrompt = func(_ context.Context, taskID string, _ PendingProposal, _ *FailureEvent) (string, error) {
435 prompts++
436 g.BindApprovalID(taskID, "esc")
437 _ = g.Resolve("esc", ActionContinue, "")
438 return "esc", nil
439 }
440 prop := Proposal{Tool: "write_file", Mutates: true, Subject: "a.go", Args: args}
441 for i := 1; i <= 2; i++ {
442 dec, err := g.BeforeMutation(context.Background(), prop)
443 if err != nil || dec.Allow || !dec.Blocked {
444 t.Fatalf("attempt %d = %+v %v", i, dec, err)
445 }
446 }
447 dec, err := g.BeforeMutation(context.Background(), prop)
448 if err != nil || dec.Allow || !dec.Blocked || !dec.StopTurn || prompts != 0 || reviews.Load() != 3 || !strings.Contains(dec.Message, "paused this turn") {
449 t.Fatalf("stop = %+v %v prompts=%d reviews=%d", dec, err, prompts, reviews.Load())
450 }
451 })
452 t.Run("reviewer error keeps low risk work automatic", func(t *testing.T) {
453 var reviews atomic.Int32
454 var prompted atomic.Bool
455 g := NewGate(Options{
456 Reviewer: reviewerFunc(func(context.Context, *FailureEvent, []string, Proposal, string) (ReviewVerdict, error) {
457 reviews.Add(1)
458 return ReviewVerdict{}, errors.New("timeout")
459 }),
460 })
461 args := json.RawMessage(`{"path":"a.go","content":"x"}`)
462 g.ObserveResult(context.Background(), Observation{
463 Tool: "write_file", Mutates: true, Subject: "a.go", ErrSummary: "fail", Args: args,
464 })
465 g.opts.EmitPrompt = func(_ context.Context, taskID string, _ PendingProposal, _ *FailureEvent) (string, error) {
466 prompted.Store(true)
467 g.BindApprovalID(taskID, "err")
468 _ = g.Resolve("err", ActionContinue, "")
469 return "err", nil
470 }
471 dec, err := g.BeforeMutation(context.Background(), Proposal{
472 Tool: "write_file", Mutates: true, Subject: "a.go", Args: args,
473 })
474 if err != nil || !dec.Allow || prompted.Load() || reviews.Load() != 1 {
475 t.Fatalf("got allow=%v prompted=%v reviews=%d err=%v", dec.Allow, prompted.Load(), reviews.Load(), err)
476 }
477 // A subsequent reject must still start at attempt 1 (error did not burn budget).
478 g.opts.Reviewer = staticReviewer{ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeUncertain, Rationale: "no"}}
479 g.opts.EmitPrompt = nil
480 dec, err = g.BeforeMutation(context.Background(), Proposal{
481 Tool: "write_file", Mutates: true, Subject: "a.go", Args: args,
482 })
483 if err != nil || dec.Allow || !dec.Blocked || !strings.Contains(dec.Message, "attempt 1/3") {
484 t.Fatalf("post-error reject = %+v %v", dec, err)
485 }
486 })
487 t.Run("reviewer error asks about structural plan transition", func(t *testing.T) {
488 got := run(t, nil, Proposal{
489 Tool: "todo_write", ReadOnly: true, PlanTransition: true,
490 PlanBefore: "1. Existing [in_progress]", PlanAfter: "1. Replacement [in_progress]",
491 }, reviewerFunc(func(context.Context, *FailureEvent, []string, Proposal, string) (ReviewVerdict, error) {
492 return ReviewVerdict{}, errors.New("timeout")
493 }))
494 if !got.allow || !got.prompted || got.reviews != 1 {
495 t.Fatalf("got %+v", got)
496 }
497 })
498 t.Run("bounded strategy and scope changes may continue", func(t *testing.T) {
499 for _, kind := range []ChangeKind{ChangeStrategy, ChangeScope} {
500 t.Run(string(kind), func(t *testing.T) {
501 got := run(t, func(g *Gate) {
502 g.ObserveResult(context.Background(), Observation{
503 Tool: "write_file", Mutates: true, ErrSummary: "fail",
504 Args: json.RawMessage(`{"path":"a.go"}`),
505 })
506 }, Proposal{
507 Tool: "write_file", Mutates: true, Args: json.RawMessage(`{"path":"b.go","content":"x"}`),
508 }, staticReviewer{ReviewVerdict{Outcome: ReviewContinue, ChangeKind: kind}})
509 if !got.allow || got.prompted || got.reviews != 0 {
510 t.Fatalf("bounded %s recovery = %+v", kind, got)
511 }
512 })
513 }
514 })
515 t.Run("risk or uncertainty cannot silently continue", func(t *testing.T) {
516 for _, kind := range []ChangeKind{ChangeRisk, ChangeUncertain} {
517 t.Run(string(kind), func(t *testing.T) {
518 args := json.RawMessage(`{"path":"a.go","content":"x"}`)
519 got := run(t, func(g *Gate) {
520 g.ObserveResult(context.Background(), Observation{
521 Tool: "write_file", Mutates: true, Subject: "a.go",
522 ErrSummary: "fail", Args: args,
523 })
524 }, Proposal{
525 Tool: "write_file", Mutates: true, Subject: "a.go", Args: args,
526 }, staticReviewer{ReviewVerdict{Outcome: ReviewContinue, ChangeKind: kind}})
527 if got.allow {
528 t.Fatalf("unsafe %s recovery auto-allowed: %+v", kind, got)
529 }
530 })
531 }
532 })
533 t.Run("headless execution risk stays on permission path", func(t *testing.T) {
534 g := NewGate(Options{Headless: true})
535 dec, err := g.BeforeMutation(context.Background(), Proposal{
536 Tool: "bash", Mutates: true, Subject: "git push origin feature",
537 Args: json.RawMessage(`{"command":"git push origin feature"}`),
538 })
539 if err != nil || !dec.Allow || dec.Blocked {
540 t.Fatalf("got %+v %v", dec, err)
541 }
542 })
543 t.Run("success verification clears failure state", func(t *testing.T) {
544 g := NewGate(Options{})
545 g.ObserveResult(context.Background(), Observation{
546 Tool: "bash", Verification: true, ErrSummary: "fail",
547 Args: json.RawMessage(`{"command":"go test"}`),
548 })
549 g.ObserveResult(context.Background(), Observation{
550 Tool: "bash", Verification: true, Success: true,
551 Args: json.RawMessage(`{"command":"go test"}`),
552 })
553 if st := g.Snapshot().Tasks["root"]; st != nil {
554 t.Fatalf("want cleared, got %+v", st)
555 }
556 })
557 }
558
559 func TestReviewerPlanDecisionOnlyAcceptsExplicitMaterialChange(t *testing.T) {
560 tests := []struct {
561 name string
562 v ReviewVerdict
563 want bool
564 }{
565 {name: "strategy confirm", v: ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeStrategy}, want: true},
566 {name: "scope confirm", v: ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeScope}, want: true},
567 {name: "bounded strategy continues", v: ReviewVerdict{Outcome: ReviewContinue, ChangeKind: ChangeStrategy}},
568 {name: "uncertain remains self-correction", v: ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeUncertain}},
569 {name: "risk stays on risk path", v: ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeRisk}},
570 {name: "same strategy confirm", v: ReviewVerdict{Outcome: ReviewConfirm, ChangeKind: ChangeSameStrategy}},
571 }
572 for _, tc := range tests {
573 t.Run(tc.name, func(t *testing.T) {
574 if got := reviewerPlanDecision(tc.v); got != tc.want {
575 t.Fatalf("reviewerPlanDecision(%+v) = %v, want %v", tc.v, got, tc.want)
576 }
577 })
578 }
579 }
580
581 func TestSafeRetryConsumedOnlyOnce(t *testing.T) {
582 g := NewGate(Options{})
583 args := json.RawMessage(`{"command":"go test ./..."}`)
584 g.ObserveResult(context.Background(), Observation{
585 Tool: "bash", Subject: "go test ./...", Verification: true, Args: args, ErrSummary: "fail",
586 })
587 dec, err := g.BeforeMutation(context.Background(), Proposal{
588 Tool: "bash", Subject: "go test ./...", Verification: true, Args: args,
589 })
590 if err != nil || !dec.Allow {
591 t.Fatalf("first retry = %+v %v", dec, err)
592 }
593 // Without re-arming, a second identical verification still has active failure
594 // but safe retry is spent → reviewer/ask path (not silent second auto-retry).
595 var reviews atomic.Int32
596 g.opts.Reviewer = reviewerFunc(func(context.Context, *FailureEvent, []string, Proposal, string) (ReviewVerdict, error) {
597 reviews.Add(1)
598 return ReviewVerdict{Outcome: ReviewContinue, ChangeKind: ChangeSameStrategy}, nil
599 })
600 dec, err = g.BeforeMutation(context.Background(), Proposal{
601 Tool: "bash", Subject: "go test ./...", Verification: true, Args: args,
602 })
603 if err != nil || !dec.Allow {
604 t.Fatalf("second attempt = %+v %v", dec, err)
605 }
606 if reviews.Load() != 1 {
607 t.Fatalf("spent safe retry must go through reviewer, reviews=%d", reviews.Load())
608 }
609 }
610
611 func TestTaskIsolationByTaskID(t *testing.T) {
612 var reviewedTask string
613 g := NewGate(Options{
614 Reviewer: reviewerFunc(func(_ context.Context, f *FailureEvent, _ []string, p Proposal, _ string) (ReviewVerdict, error) {
615 reviewedTask = p.TaskID
616 if f != nil && f.TaskID != "" && f.TaskID != normalizeTaskID(p.TaskID) {
617 return ReviewVerdict{}, fmt.Errorf("failure task %q proposal task %q", f.TaskID, p.TaskID)
618 }
619 return ReviewVerdict{Outcome: ReviewContinue, ChangeKind: ChangeSameStrategy}, nil
620 }),
621 })
622 g.ObserveResult(context.Background(), Observation{
623 TaskID: "subagent:a", Tool: "write_file", Mutates: true, ErrSummary: "fail-a",
624 Args: json.RawMessage(`{"path":"a.go"}`),
625 })
626 g.ObserveResult(context.Background(), Observation{
627 TaskID: "subagent:b", Tool: "write_file", Mutates: true, ErrSummary: "fail-b",
628 Args: json.RawMessage(`{"path":"b.go"}`),
629 })
630 dec, err := g.BeforeMutation(context.Background(), Proposal{
631 TaskID: "subagent:a", Tool: "write_file", Mutates: true,
632 Args: json.RawMessage(`{"path":"a.go"}`),
633 })
634 if err != nil || !dec.Allow {
635 t.Fatalf("task a = %+v %v", dec, err)
636 }
637 if reviewedTask != "subagent:a" {
638 t.Fatalf("reviewed task = %q", reviewedTask)
639 }
640 // Task B still has its own failure; task A success does not clear it.
641 g.ObserveResult(context.Background(), Observation{
642 TaskID: "subagent:a", Tool: "write_file", Mutates: true, Success: true,
643 Args: json.RawMessage(`{"path":"a.go"}`),
644 })
645 if st := g.Snapshot().Tasks["subagent:b"]; st == nil || st.Failure == nil {
646 t.Fatalf("task b failure cleared incorrectly: %+v", st)
647 }
648 if st := g.Snapshot().Tasks["subagent:a"]; st != nil {
649 t.Fatalf("task a should be cleared: %+v", st)
650 }
651 }
652
653 func TestResolveSyncNoDeadlock(t *testing.T) {
654 g := NewGate(Options{})
655 g.ObserveResult(context.Background(), Observation{
656 Tool: "bash", Verification: true, ErrSummary: "fail",
657 Args: json.RawMessage(`{"command":"go test"}`),
658 })
659 // EmitPrompt resolves synchronously before returning — must not deadlock.
660 g.opts.EmitPrompt = func(_ context.Context, taskID string, _ PendingProposal, _ *FailureEvent) (string, error) {
661 g.BindApprovalID(taskID, "sync")
662 if err := g.Resolve("sync", ActionContinue, ""); err != nil {
663 return "", err
664 }
665 return "sync", nil
666 }
667 done := make(chan struct {
668 dec Decision
669 err error
670 }, 1)
671 go func() {
672 dec, err := g.BeforeMutation(context.Background(), Proposal{
673 Tool: "write_file", Mutates: true, StrategyChanged: true,
674 Args: json.RawMessage(`{"path":"a.go"}`),
675 })
676 done <- struct {
677 dec Decision
678 err error
679 }{dec, err}
680 }()
681 select {
682 case got := <-done:
683 if got.err != nil || !got.dec.Allow {
684 t.Fatalf("want allow, got %+v %v", got.dec, got.err)
685 }
686 case <-time.After(2 * time.Second):
687 t.Fatal("deadlock: synchronous Resolve did not unblock BeforeMutation")
688 }
689 }
690
690 lines GO