返回 DeepSeek-Reasonix
bgjobs_test.go
根目录 / internal / tool / builtin / bgjobs_test.go
1 package builtin
2
3 import (
4 "context"
5 "io"
6 "strings"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/evidence"
11 "reasonix/internal/jobs"
12 "reasonix/internal/planmode"
13 )
14
15 // End-to-end through the actual tools: a background bash job runs under a manager
16 // injected on the context, the wait tool collects its output, and bash_output
17 // reads it — the same path the agent drives.
18 func TestBackgroundBashWaitAndOutput(t *testing.T) {
19 m := jobs.NewManager(event.Discard)
20 defer m.Close()
21 ctx := jobs.WithManager(context.Background(), m)
22
23 start, err := bash{}.Execute(ctx, []byte(`{"command":"printf hello; sleep 0.3","run_in_background":true}`))
24 if err != nil {
25 t.Fatalf("bash background: %v", err)
26 }
27 if !strings.Contains(start, "Started background job") {
28 t.Fatalf("unexpected start message: %q", start)
29 }
30
31 // The job is registered and running synchronously before Execute returns.
32 running := m.Running()
33 if len(running) != 1 {
34 t.Fatalf("want 1 running job, got %d", len(running))
35 }
36 id := running[0].ID
37
38 // wait blocks until it finishes, then returns its output.
39 wout, err := waitJob{}.Execute(ctx, []byte(`{"job_ids":["`+id+`"]}`))
40 if err != nil {
41 t.Fatalf("wait: %v", err)
42 }
43 if !strings.Contains(wout, "done") || !strings.Contains(wout, "hello") {
44 t.Errorf("wait output = %q, want it to report done with hello", wout)
45 }
46
47 // bash_output reads the buffered output (wait doesn't consume the read cursor).
48 bo, err := bashOutput{}.Execute(ctx, []byte(`{"job_id":"`+id+`"}`))
49 if err != nil {
50 t.Fatalf("bash_output: %v", err)
51 }
52 if !strings.Contains(bo, "hello") {
53 t.Errorf("bash_output = %q, want hello", bo)
54 }
55 }
56
57 func TestWaitMergesBackgroundEvidenceExactlyOnce(t *testing.T) {
58 m := jobs.NewManager(event.Discard)
59 defer m.Close()
60 ledger := evidence.NewLedger()
61 ctx := jobs.WithManager(context.Background(), m)
62 ctx = jobs.WithSession(ctx, "session")
63 ctx = evidence.WithLedger(ctx, ledger)
64
65 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
66 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
67 ToolName: "write_file",
68 Success: true,
69 Mutation: true,
70 Write: true,
71 Paths: []string{"changed.go"},
72 }}})
73 return "done", nil
74 })
75
76 args := []byte(`{"job_ids":["` + j.ID + `"]}`)
77 if _, err := (waitJob{}).Execute(ctx, args); err != nil {
78 t.Fatalf("wait: %v", err)
79 }
80 if !ledger.Summary().HasMutation() {
81 t.Fatal("wait did not merge the task job's mutation evidence")
82 }
83 firstLen := ledger.Len()
84 if _, err := (waitJob{}).Execute(ctx, args); err != nil {
85 t.Fatalf("second wait: %v", err)
86 }
87 if got := ledger.Len(); got != firstLen {
88 t.Fatalf("second wait duplicated evidence: len %d -> %d", firstLen, got)
89 }
90 }
91
92 func TestWaitWithoutLedgerDoesNotConsumeBackgroundEvidence(t *testing.T) {
93 m := jobs.NewManager(event.Discard)
94 defer m.Close()
95 baseCtx := jobs.WithSession(jobs.WithManager(context.Background(), m), "session")
96 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
97 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
98 ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"changed.go"},
99 }}})
100 return "done", nil
101 })
102 args := []byte(`{"job_ids":["` + j.ID + `"]}`)
103 if _, err := (waitJob{}).Execute(baseCtx, args); err != nil {
104 t.Fatalf("wait without ledger: %v", err)
105 }
106
107 ledger := evidence.NewLedger()
108 if _, err := (waitJob{}).Execute(evidence.WithLedger(baseCtx, ledger), args); err != nil {
109 t.Fatalf("wait with ledger: %v", err)
110 }
111 if !ledger.Summary().HasMutation() {
112 t.Fatal("wait without a ledger consumed evidence before a collecting turn could merge it")
113 }
114 }
115
116 func TestWaitInPlanModeDefersBackgroundEvidence(t *testing.T) {
117 m := jobs.NewManager(event.Discard)
118 defer m.Close()
119 baseCtx := jobs.WithSession(jobs.WithManager(context.Background(), m), "session")
120 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
121 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
122 ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"changed.go"},
123 }}})
124 return "done", nil
125 })
126 args := []byte(`{"job_ids":["` + j.ID + `"]}`)
127
128 // A planning turn may wait on jobs, but merging mutation receipts there
129 // would arm delivery sign-off demands the read-only turn cannot satisfy.
130 planLedger := evidence.NewLedger()
131 planCtx := planmode.WithActive(evidence.WithLedger(baseCtx, planLedger), true)
132 if _, err := (waitJob{}).Execute(planCtx, args); err != nil {
133 t.Fatalf("wait in plan mode: %v", err)
134 }
135 if planLedger.Summary().HasMutation() {
136 t.Fatal("plan-mode wait merged mutation evidence into the planning turn")
137 }
138
139 // The evidence stays on the job for the first normal turn to collect.
140 ledger := evidence.NewLedger()
141 normalCtx := planmode.WithActive(evidence.WithLedger(baseCtx, ledger), false)
142 if _, err := (waitJob{}).Execute(normalCtx, args); err != nil {
143 t.Fatalf("wait after plan mode: %v", err)
144 }
145 if !ledger.Summary().HasMutation() {
146 t.Fatal("plan-mode wait consumed the background evidence instead of deferring it")
147 }
148 }
149
150 // kill_shell terminates a long-running background job.
151 func TestBackgroundKill(t *testing.T) {
152 m := jobs.NewManager(event.Discard)
153 defer m.Close()
154 ctx := jobs.WithManager(context.Background(), m)
155
156 if _, err := (bash{}).Execute(ctx, []byte(`{"command":"sleep 120","run_in_background":true}`)); err != nil {
157 t.Fatalf("bash background: %v", err)
158 }
159 id := m.Running()[0].ID
160
161 kout, err := killShell{}.Execute(ctx, []byte(`{"job_id":"`+id+`"}`))
162 if err != nil {
163 t.Fatalf("kill_shell: %v", err)
164 }
165 if !strings.Contains(kout, "Killed") {
166 t.Errorf("kill_shell = %q, want it to report Killed", kout)
167 }
168 // 120s natural duration keeps the job far from finishing on its own, so the
169 // reap window is the only thing this measures: a loaded machine's slow
170 // process-tree teardown (up to ~bashWaitDelay) still fits, while a genuinely
171 // broken kill trips the 40s timeout. Pairing the sleep with the timeout (as
172 // 10/10 did) raced natural completion against the reap.
173 res := m.Wait(ctx, []string{id}, 40)
174 if len(res) != 1 || res[0].Status != jobs.Killed {
175 t.Fatalf("want killed, got %+v", res)
176 }
177 }
178
179 // kill_shell flips a job's status to Killed synchronously, well before its
180 // cancelled run goroutine actually unwinds, flushes PublishEvidence, and closes
181 // done. A bash_output poll that lands in that window must not note an empty
182 // lease: the ledger's lease is idempotent per turn, so noting it early would
183 // dedupe away every later retry in this same turn while the job's real
184 // mutation evidence is still forthcoming — and a turn that goes on to deliver
185 // would then commit (permanently drain) evidence nobody ever merged or
186 // reviewed. This deterministically drives that exact window with channels
187 // instead of a timing race.
188 func TestKilledJobBashOutputDoesNotNoteLeaseBeforeEvidenceIsReady(t *testing.T) {
189 m := jobs.NewManager(event.Discard)
190 defer m.Close()
191 ledger := evidence.NewLedger()
192 ctx := jobs.WithManager(context.Background(), m)
193 ctx = jobs.WithSession(ctx, "session")
194 ctx = evidence.WithLedger(ctx, ledger)
195
196 cancelSeen := make(chan struct{})
197 release := make(chan struct{})
198 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
199 <-jobCtx.Done()
200 close(cancelSeen)
201 // Simulate a job that keeps unwinding (e.g. a subprocess still tearing
202 // down) after cancellation is requested but before it actually returns.
203 <-release
204 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
205 ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"changed.go"},
206 }}})
207 return "", context.Canceled
208 })
209
210 if _, err := (killShell{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`"}`)); err != nil {
211 t.Fatalf("kill_shell: %v", err)
212 }
213 <-cancelSeen // the goroutine observed the cancellation but has not returned
214
215 // bash_output lands in the unwinding window: status already reports Killed,
216 // but the job's done channel is not closed yet and no evidence exists.
217 bo, err := bashOutput{}.Execute(ctx, []byte(`{"job_id":"`+j.ID+`"}`))
218 if err != nil {
219 t.Fatalf("bash_output during unwind: %v", err)
220 }
221 if !strings.Contains(bo, "killed") {
222 t.Fatalf("bash_output during unwind = %q, want killed status", bo)
223 }
224 if ledger.Summary().HasMutation() {
225 t.Fatal("bash_output merged mutation evidence before the job was ready")
226 }
227 if leases := ledger.BackgroundLeases(); len(leases) != 0 {
228 t.Fatalf("bash_output noted a lease before the job was ready: %+v", leases)
229 }
230
231 close(release)
232 if res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 5); len(res) != 1 || res[0].Status != jobs.Killed {
233 t.Fatalf("post-unwind wait = %+v, want one killed result", res)
234 }
235
236 // A later retry — the model calling bash_output again, or the next turn's
237 // automatic re-lease — must still find the evidence, not a dead dedupe entry.
238 if _, err := (bashOutput{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`"}`)); err != nil {
239 t.Fatalf("bash_output after unwind: %v", err)
240 }
241 if !ledger.Summary().HasMutation() {
242 t.Fatal("bash_output did not collect the killed job's evidence once it became ready")
243 }
244 if leases := ledger.BackgroundLeases(); len(leases) != 1 {
245 t.Fatalf("leases = %+v, want exactly one lease recorded", leases)
246 }
247 }
248
249 // Without a manager on the context the background tools degrade to a clear error
250 // rather than panicking.
251 func TestBackgroundToolsNoManager(t *testing.T) {
252 ctx := context.Background()
253 if _, err := (bashOutput{}).Execute(ctx, []byte(`{"job_id":"bash-1"}`)); err == nil {
254 t.Error("bash_output without a manager should error")
255 }
256 if _, err := (bash{}).Execute(ctx, []byte(`{"command":"echo hi","run_in_background":true}`)); err == nil {
257 t.Error("background bash without a manager should error")
258 }
259 }
260
260 lines GO