返回 DeepSeek-Reasonix
bgjobs_test.go
根目录 / internal / tool / builtin / bgjobs_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "strings"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 "reasonix/internal/jobs"
14 "reasonix/internal/planmode"
15 "reasonix/internal/tool"
16 )
17
18 // End-to-end through the actual tools: a background bash job runs under a manager
19 // injected on the context, the wait tool collects its output, and bash_output
20 // reads it — the same path the agent drives.
21 func TestBackgroundBashWaitAndOutput(t *testing.T) {
22 requirePOSIXShellTest(t)
23 m := jobs.NewManager(event.Discard)
24 defer m.Close()
25 ctx := jobs.WithManager(context.Background(), m)
26 ctx = fullAccessBashTestContext(ctx)
27
28 start, err := bash{}.Execute(ctx, []byte(`{"command":"printf hello; sleep 0.3","run_in_background":true}`))
29 if err != nil {
30 t.Fatalf("bash background: %v", err)
31 }
32 if !strings.Contains(start, "Started background job") {
33 t.Fatalf("unexpected start message: %q", start)
34 }
35
36 // The job is registered and running synchronously before Execute returns.
37 running := m.Running()
38 if len(running) != 1 {
39 t.Fatalf("want 1 running job, got %d", len(running))
40 }
41 id := running[0].ID
42
43 // wait blocks until it finishes, then returns its output.
44 wout, err := waitJob{}.Execute(ctx, []byte(`{"job_ids":["`+id+`"]}`))
45 if err != nil {
46 t.Fatalf("wait: %v", err)
47 }
48 if !strings.Contains(wout, "done") || !strings.Contains(wout, "hello") {
49 t.Errorf("wait output = %q, want it to report done with hello", wout)
50 }
51
52 // bash_output reads the buffered output (wait doesn't consume the read cursor).
53 bo, err := bashOutput{}.Execute(ctx, []byte(`{"job_id":"`+id+`"}`))
54 if err != nil {
55 t.Fatalf("bash_output: %v", err)
56 }
57 if !strings.Contains(bo, "hello") {
58 t.Errorf("bash_output = %q, want hello", bo)
59 }
60 }
61
62 func TestJobOutputWaitsAndReturnsIncrementalStatus(t *testing.T) {
63 m := jobs.NewManager(event.Discard)
64 defer m.Close()
65 ctx := jobs.WithManager(context.Background(), m)
66 j := m.Start("pwsh", "server", func(_ context.Context, out io.Writer) (string, error) {
67 _, _ = io.WriteString(out, "ready\n")
68 return "", nil
69 })
70
71 got, err := (jobOutput{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`","wait":true,"timeout_ms":1000}`))
72 if err != nil {
73 t.Fatalf("job_output: %v", err)
74 }
75 if !strings.Contains(got, "ready") || !strings.Contains(got, "[status: done]") {
76 t.Fatalf("job_output = %q", got)
77 }
78 if !strings.HasPrefix(j.ID, "pwsh-") {
79 t.Fatalf("job id = %q, want pwsh prefix", j.ID)
80 }
81 }
82
83 func TestJobOutputTimeoutLeavesJobRunningAndJobKillStopsIt(t *testing.T) {
84 m := jobs.NewManager(event.Discard)
85 defer m.Close()
86 ctx := jobs.WithManager(context.Background(), m)
87 j := m.Start("pwsh", "server", func(ctx context.Context, _ io.Writer) (string, error) {
88 <-ctx.Done()
89 return "", ctx.Err()
90 })
91
92 got, err := (jobOutput{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`","wait":true,"timeout_ms":10}`))
93 if err != nil {
94 t.Fatalf("job_output timeout: %v", err)
95 }
96 if !strings.Contains(got, "[status: running]") {
97 t.Fatalf("timed wait = %q", got)
98 }
99 killed, err := (jobKill{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`","reason":"test complete"}`))
100 if err != nil || !strings.Contains(killed, "Requested cancellation") {
101 t.Fatalf("job_kill = %q, %v", killed, err)
102 }
103 res := m.Wait(ctx, []string{j.ID}, 5)
104 if len(res) != 1 || res[0].Status != jobs.Killed {
105 t.Fatalf("job after kill = %+v", res)
106 }
107 }
108
109 func TestJobOutputCarriesBackgroundShellFailureMetadata(t *testing.T) {
110 m := jobs.NewManager(event.Discard)
111 defer m.Close()
112 ctx := jobs.WithSession(jobs.WithManager(context.Background(), m), "session")
113 j := m.StartForSession("session", "pwsh", "runner failure", func(jobCtx context.Context, _ io.Writer) (string, error) {
114 jobs.SetExecution(jobCtx, &tool.ShellExecution{
115 Kind: "shell",
116 Shell: tool.ShellNamePwsh,
117 State: tool.ShellStateNotRun,
118 FailurePhase: tool.ShellPhaseAuthorization,
119 MutationRisk: tool.ShellMutationNotStarted,
120 })
121 return "", errors.New("ACL initialization failed")
122 })
123 result, err := (jobOutput{}).ExecuteDetailed(ctx, json.RawMessage(`{"job_id":"`+j.ID+`","wait":true,"timeout_ms":1000}`))
124 if err != nil {
125 t.Fatal(err)
126 }
127 if result.Execution == nil || result.Execution.FailurePhase != tool.ShellPhaseAuthorization || result.Execution.MutationRisk != tool.ShellMutationNotStarted {
128 t.Fatalf("job_output execution metadata = %+v", result.Execution)
129 }
130 }
131
132 func TestLegacyJobAliasesStayCallableButHiddenFromCapabilityCatalog(t *testing.T) {
133 registry := tool.NewRegistry()
134 registry.Add(jobOutput{})
135 registry.Add(jobKill{})
136 registry.Add(bashOutput{})
137 registry.Add(waitJob{})
138 registry.Add(killShell{})
139 want := map[string]bool{"job_output": true, "job_kill": true}
140 for _, entry := range registry.CapabilityContractEntries() {
141 if !want[entry.Name] {
142 t.Fatalf("compatibility alias leaked into catalog: %q", entry.Name)
143 }
144 delete(want, entry.Name)
145 }
146 if len(want) != 0 {
147 t.Fatalf("formal job tools missing from catalog: %v", want)
148 }
149 for _, legacy := range []string{"bash_output", "wait", "kill_shell"} {
150 if resolved, canonical, ambiguous := registry.ResolveCall(legacy); resolved == nil || canonical != legacy || len(ambiguous) != 0 {
151 t.Fatalf("legacy route %q is not executable", legacy)
152 }
153 }
154 }
155
156 func TestWaitMergesBackgroundEvidenceExactlyOnce(t *testing.T) {
157 m := jobs.NewManager(event.Discard)
158 defer m.Close()
159 ledger := evidence.NewLedger()
160 ctx := jobs.WithManager(context.Background(), m)
161 ctx = jobs.WithSession(ctx, "session")
162 ctx = evidence.WithLedger(ctx, ledger)
163
164 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
165 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
166 ToolName: "write_file",
167 Success: true,
168 Mutation: true,
169 Write: true,
170 Paths: []string{"changed.go"},
171 }}})
172 return "done", nil
173 })
174
175 args := []byte(`{"job_ids":["` + j.ID + `"]}`)
176 if _, err := (waitJob{}).Execute(ctx, args); err != nil {
177 t.Fatalf("wait: %v", err)
178 }
179 if !ledger.Summary().HasMutation() {
180 t.Fatal("wait did not merge the task job's mutation evidence")
181 }
182 firstLen := ledger.Len()
183 if _, err := (waitJob{}).Execute(ctx, args); err != nil {
184 t.Fatalf("second wait: %v", err)
185 }
186 if got := ledger.Len(); got != firstLen {
187 t.Fatalf("second wait duplicated evidence: len %d -> %d", firstLen, got)
188 }
189 }
190
191 func TestWaitWithoutLedgerDoesNotConsumeBackgroundEvidence(t *testing.T) {
192 m := jobs.NewManager(event.Discard)
193 defer m.Close()
194 baseCtx := jobs.WithSession(jobs.WithManager(context.Background(), m), "session")
195 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
196 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
197 ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"changed.go"},
198 }}})
199 return "done", nil
200 })
201 args := []byte(`{"job_ids":["` + j.ID + `"]}`)
202 if _, err := (waitJob{}).Execute(baseCtx, args); err != nil {
203 t.Fatalf("wait without ledger: %v", err)
204 }
205
206 ledger := evidence.NewLedger()
207 if _, err := (waitJob{}).Execute(evidence.WithLedger(baseCtx, ledger), args); err != nil {
208 t.Fatalf("wait with ledger: %v", err)
209 }
210 if !ledger.Summary().HasMutation() {
211 t.Fatal("wait without a ledger consumed evidence before a collecting turn could merge it")
212 }
213 }
214
215 func TestWaitInPlanModeDefersBackgroundEvidence(t *testing.T) {
216 m := jobs.NewManager(event.Discard)
217 defer m.Close()
218 baseCtx := jobs.WithSession(jobs.WithManager(context.Background(), m), "session")
219 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
220 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
221 ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"changed.go"},
222 }}})
223 return "done", nil
224 })
225 args := []byte(`{"job_ids":["` + j.ID + `"]}`)
226
227 // A planning turn may wait on jobs, but merging mutation receipts there
228 // would arm delivery sign-off demands the read-only turn cannot satisfy.
229 planLedger := evidence.NewLedger()
230 planCtx := planmode.WithActive(evidence.WithLedger(baseCtx, planLedger), true)
231 if _, err := (waitJob{}).Execute(planCtx, args); err != nil {
232 t.Fatalf("wait in plan mode: %v", err)
233 }
234 if planLedger.Summary().HasMutation() {
235 t.Fatal("plan-mode wait merged mutation evidence into the planning turn")
236 }
237
238 // The evidence stays on the job for the first normal turn to collect.
239 ledger := evidence.NewLedger()
240 normalCtx := planmode.WithActive(evidence.WithLedger(baseCtx, ledger), false)
241 if _, err := (waitJob{}).Execute(normalCtx, args); err != nil {
242 t.Fatalf("wait after plan mode: %v", err)
243 }
244 if !ledger.Summary().HasMutation() {
245 t.Fatal("plan-mode wait consumed the background evidence instead of deferring it")
246 }
247 }
248
249 // kill_shell terminates a long-running background job.
250 func TestBackgroundKill(t *testing.T) {
251 requirePOSIXShellTest(t)
252 m := jobs.NewManager(event.Discard)
253 defer m.Close()
254 ctx := jobs.WithManager(context.Background(), m)
255 ctx = fullAccessBashTestContext(ctx)
256
257 if _, err := (bash{}).Execute(ctx, []byte(`{"command":"sleep 120","run_in_background":true}`)); err != nil {
258 t.Fatalf("bash background: %v", err)
259 }
260 id := m.Running()[0].ID
261
262 kout, err := killShell{}.Execute(ctx, []byte(`{"job_id":"`+id+`"}`))
263 if err != nil {
264 t.Fatalf("kill_shell: %v", err)
265 }
266 if !strings.Contains(kout, "Killed") {
267 t.Errorf("kill_shell = %q, want it to report Killed", kout)
268 }
269 // 120s natural duration keeps the job far from finishing on its own, so the
270 // reap window is the only thing this measures: a loaded machine's slow
271 // process-tree teardown (up to ~bashWaitDelay) still fits, while a genuinely
272 // broken kill trips the 40s timeout. Pairing the sleep with the timeout (as
273 // 10/10 did) raced natural completion against the reap.
274 res := m.Wait(ctx, []string{id}, 40)
275 if len(res) != 1 || res[0].Status != jobs.Killed {
276 t.Fatalf("want killed, got %+v", res)
277 }
278 }
279
280 // kill_shell flips a job's status to Killed synchronously, well before its
281 // cancelled run goroutine actually unwinds, flushes PublishEvidence, and closes
282 // done. A bash_output poll that lands in that window must not note an empty
283 // lease: the ledger's lease is idempotent per turn, so noting it early would
284 // dedupe away every later retry in this same turn while the job's real
285 // mutation evidence is still forthcoming — and a turn that goes on to deliver
286 // would then commit (permanently drain) evidence nobody ever merged or
287 // reviewed. This deterministically drives that exact window with channels
288 // instead of a timing race.
289 func TestKilledJobBashOutputDoesNotNoteLeaseBeforeEvidenceIsReady(t *testing.T) {
290 m := jobs.NewManager(event.Discard)
291 defer m.Close()
292 ledger := evidence.NewLedger()
293 ctx := jobs.WithManager(context.Background(), m)
294 ctx = jobs.WithSession(ctx, "session")
295 ctx = evidence.WithLedger(ctx, ledger)
296
297 cancelSeen := make(chan struct{})
298 release := make(chan struct{})
299 j := m.StartForSession("session", "task", "writer", func(jobCtx context.Context, _ io.Writer) (string, error) {
300 <-jobCtx.Done()
301 close(cancelSeen)
302 // Simulate a job that keeps unwinding (e.g. a subprocess still tearing
303 // down) after cancellation is requested but before it actually returns.
304 <-release
305 jobs.PublishEvidence(jobCtx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
306 ToolName: "write_file", Success: true, Mutation: true, Write: true, Paths: []string{"changed.go"},
307 }}})
308 return "", context.Canceled
309 })
310
311 if _, err := (killShell{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`"}`)); err != nil {
312 t.Fatalf("kill_shell: %v", err)
313 }
314 <-cancelSeen // the goroutine observed the cancellation but has not returned
315
316 // bash_output lands in the unwinding window: status already reports Killed,
317 // but the job's done channel is not closed yet and no evidence exists.
318 bo, err := bashOutput{}.Execute(ctx, []byte(`{"job_id":"`+j.ID+`"}`))
319 if err != nil {
320 t.Fatalf("bash_output during unwind: %v", err)
321 }
322 if !strings.Contains(bo, "killed") {
323 t.Fatalf("bash_output during unwind = %q, want killed status", bo)
324 }
325 if ledger.Summary().HasMutation() {
326 t.Fatal("bash_output merged mutation evidence before the job was ready")
327 }
328 if leases := ledger.BackgroundLeases(); len(leases) != 0 {
329 t.Fatalf("bash_output noted a lease before the job was ready: %+v", leases)
330 }
331
332 close(release)
333 if res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 5); len(res) != 1 || res[0].Status != jobs.Killed {
334 t.Fatalf("post-unwind wait = %+v, want one killed result", res)
335 }
336
337 // A later retry — the model calling bash_output again, or the next turn's
338 // automatic re-lease — must still find the evidence, not a dead dedupe entry.
339 if _, err := (bashOutput{}).Execute(ctx, []byte(`{"job_id":"`+j.ID+`"}`)); err != nil {
340 t.Fatalf("bash_output after unwind: %v", err)
341 }
342 if !ledger.Summary().HasMutation() {
343 t.Fatal("bash_output did not collect the killed job's evidence once it became ready")
344 }
345 if leases := ledger.BackgroundLeases(); len(leases) != 1 {
346 t.Fatalf("leases = %+v, want exactly one lease recorded", leases)
347 }
348 }
349
350 // Without a manager on the context the background tools degrade to a clear error
351 // rather than panicking.
352 func TestBackgroundToolsNoManager(t *testing.T) {
353 ctx := context.Background()
354 if _, err := (bashOutput{}).Execute(ctx, []byte(`{"job_id":"bash-1"}`)); err == nil {
355 t.Error("bash_output without a manager should error")
356 }
357 if _, err := (bash{}).Execute(ctx, []byte(`{"command":"echo hi","run_in_background":true}`)); err == nil {
358 t.Error("background bash without a manager should error")
359 }
360 }
361
361 lines GO