返回 DeepSeek-Reasonix
artifacts_test.go
根目录 / internal / jobs / artifacts_test.go
1 package jobs
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "reasonix/internal/event"
17 "reasonix/internal/evidence"
18 )
19
20 func TestCompletedJobPersistsOutputAndReleasesMemory(t *testing.T) {
21 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
22 m := NewManager(event.Discard)
23 defer m.Close()
24 m.SetActiveSessionPath("session", sessionPath)
25
26 j := m.StartForSession("session", "bash", "persist", func(_ context.Context, out io.Writer) (string, error) {
27 _, _ = io.WriteString(out, strings.Repeat("x", defaultTailBytes+1024))
28 return "", nil
29 })
30 <-j.done
31
32 j.mu.Lock()
33 tailLen := len(j.tail)
34 result := j.result
35 artifactPath := j.artifactPath
36 j.mu.Unlock()
37
38 if tailLen != 0 {
39 t.Fatalf("completed artifact-backed job kept %d tail bytes, want 0", tailLen)
40 }
41 if result != "" {
42 t.Fatalf("completed artifact-backed job kept result %q, want empty", result)
43 }
44 if artifactPath == "" {
45 t.Fatal("artifact path should be set")
46 }
47
48 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
49 if len(res) != 1 || len(res[0].Output) != defaultTailBytes+1024 {
50 t.Fatalf("wait output len = %d, want %d", len(res[0].Output), defaultTailBytes+1024)
51 }
52 }
53
54 func TestJobArtifactPreservesOutput(t *testing.T) {
55 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
56 m := NewManager(event.Discard)
57 defer m.Close()
58 m.SetActiveSessionPath("session", sessionPath)
59 secret := "sk-real-secret-value-123456"
60
61 j := m.StartForSession("session", "bash", "persist secret", func(_ context.Context, out io.Writer) (string, error) {
62 _, _ = io.WriteString(out, "DEEPSEEK_API_KEY="+secret+"\n")
63 return "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz", nil
64 })
65 <-j.done
66
67 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
68 if len(res) != 1 {
69 t.Fatalf("wait result = %+v", res)
70 }
71 if !strings.Contains(res[0].Output, secret) || !strings.Contains(res[0].Output, "ghp_abcdefghijklmnopqrstuvwxyz") {
72 t.Fatalf("wait output did not preserve job output:\n%s", res[0].Output)
73 }
74
75 data, err := os.ReadFile(filepath.Join(ArtifactDir(sessionPath), j.ID+jobLogExt))
76 if err != nil {
77 t.Fatalf("read artifact: %v", err)
78 }
79 if !strings.Contains(string(data), secret) || !strings.Contains(string(data), "ghp_abcdefghijklmnopqrstuvwxyz") {
80 t.Fatalf("artifact did not preserve job output:\n%s", data)
81 }
82 }
83
84 func TestJobArtifactMetadataPreservesLabel(t *testing.T) {
85 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
86 m := NewManager(event.Discard)
87 defer m.Close()
88 m.SetActiveSessionPath("session", sessionPath)
89 const secret = "sk-real-secret-value-123456"
90
91 j := m.StartForSession("session", "bash", "echo DEEPSEEK_API_KEY="+secret, func(context.Context, io.Writer) (string, error) {
92 return "", nil
93 })
94 <-j.done
95
96 data, err := os.ReadFile(filepath.Join(ArtifactDir(sessionPath), j.ID+jobMetaExt))
97 if err != nil {
98 t.Fatal(err)
99 }
100 if !strings.Contains(string(data), secret) {
101 t.Fatalf("job metadata did not preserve label:\n%s", data)
102 }
103 }
104
105 func TestListArtifactViewsVerifiesTerminalArtifactPresence(t *testing.T) {
106 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
107 dir := ArtifactDir(sessionPath)
108 if err := os.MkdirAll(dir, 0o700); err != nil {
109 t.Fatal(err)
110 }
111 type artifactCase struct {
112 id string
113 status Status
114 metaOK bool
115 metaErr string
116 artifact string
117 legacy bool
118 want bool
119 }
120 cases := []artifactCase{
121 {id: "task-running", status: Running, metaOK: true, artifact: "file", want: false},
122 {id: "task-unknown", status: Status("future"), metaOK: true, artifact: "file", want: false},
123 {id: "task-missing", status: Done, metaOK: true, want: false},
124 {id: "task-error", status: Done, metaOK: true, metaErr: "write failed", artifact: "file", want: false},
125 {id: "task-directory", status: Done, metaOK: true, artifact: "directory", want: false},
126 {id: "task-complete", status: Done, metaOK: true, artifact: "file", want: true},
127 {id: "task-legacy", status: Done, metaOK: true, artifact: "file", legacy: true, want: true},
128 }
129 for _, tc := range cases {
130 logName := tc.id + jobLogExt
131 metaLogPath := logName
132 if tc.legacy {
133 metaLogPath = ""
134 }
135 if err := writeMeta(filepath.Join(dir, tc.id+jobMetaExt), artifactMeta{
136 ID: tc.id,
137 Kind: "task",
138 Status: tc.status,
139 StartedAt: time.Now().Add(-time.Minute).UnixMilli(),
140 FinishedAt: time.Now().UnixMilli(),
141 ArtifactComplete: tc.metaOK,
142 ArtifactError: tc.metaErr,
143 LogPath: metaLogPath,
144 }); err != nil {
145 t.Fatalf("write %s metadata: %v", tc.id, err)
146 }
147 switch tc.artifact {
148 case "file":
149 if err := os.WriteFile(filepath.Join(dir, logName), []byte("persisted output"), 0o600); err != nil {
150 t.Fatalf("write %s artifact: %v", tc.id, err)
151 }
152 case "directory":
153 if err := os.Mkdir(filepath.Join(dir, logName), 0o700); err != nil {
154 t.Fatalf("create %s artifact directory: %v", tc.id, err)
155 }
156 }
157 }
158
159 views, err := ListArtifactViews(sessionPath)
160 if err != nil {
161 t.Fatal(err)
162 }
163 got := make(map[string]bool, len(views))
164 for _, view := range views {
165 got[view.ID] = view.ArtifactComplete
166 }
167 if len(got) != len(cases) {
168 t.Fatalf("artifact views = %+v, want %d entries", views, len(cases))
169 }
170 for _, tc := range cases {
171 complete, ok := got[tc.id]
172 if !ok {
173 t.Errorf("%s artifact view is missing", tc.id)
174 continue
175 }
176 if complete != tc.want {
177 t.Errorf("%s artifact complete = %v, want %v", tc.id, complete, tc.want)
178 }
179 }
180 }
181
182 func TestJobArtifactUsesPrivatePermissions(t *testing.T) {
183 if runtime.GOOS == "windows" {
184 t.Skip("Windows file ACLs are not represented by Unix permission bits")
185 }
186 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
187 m := NewManager(event.Discard)
188 defer m.Close()
189 m.SetActiveSessionPath("session", sessionPath)
190
191 // Simulate a legacy artifact at the path the first job will reuse.
192 dir := ArtifactDir(sessionPath)
193 if err := os.MkdirAll(dir, 0o755); err != nil {
194 t.Fatal(err)
195 }
196 logPath := filepath.Join(dir, "bash-1"+jobLogExt)
197 if err := os.WriteFile(logPath, []byte("old redacted output"), 0o644); err != nil {
198 t.Fatal(err)
199 }
200
201 j := m.StartForSession("session", "bash", "echo API_KEY=raw-secret", func(_ context.Context, out io.Writer) (string, error) {
202 _, _ = io.WriteString(out, "API_KEY=raw-secret\n")
203 return "", nil
204 })
205 <-j.done
206 if j.ID != "bash-1" {
207 t.Fatalf("job id = %q, want bash-1", j.ID)
208 }
209 assertPrivateArtifactMode(t, logPath)
210 assertPrivateArtifactMode(t, filepath.Join(dir, j.ID+jobMetaExt))
211 info, err := os.Stat(dir)
212 if err != nil {
213 t.Fatal(err)
214 }
215 if got := info.Mode().Perm(); got != 0o700 {
216 t.Fatalf("artifact dir mode = %04o, want 0700", got)
217 }
218 }
219
220 func assertPrivateArtifactMode(t *testing.T, path string) {
221 t.Helper()
222 info, err := os.Stat(path)
223 if err != nil {
224 t.Fatalf("stat %s: %v", path, err)
225 }
226 if got := info.Mode().Perm(); got != 0o600 {
227 t.Fatalf("%s mode = %04o, want 0600", path, got)
228 }
229 }
230
231 func TestRestoreSessionArtifactsAndAdvanceSequence(t *testing.T) {
232 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
233 first := NewManager(event.Discard)
234 first.SetActiveSessionPath("session", sessionPath)
235 j := first.StartForSession("session", "task", "answer", func(context.Context, io.Writer) (string, error) {
236 return "persisted answer", nil
237 })
238 <-j.done
239 first.Close()
240
241 second := NewManager(event.Discard)
242 defer second.Close()
243 second.SetActiveSessionPath("session", sessionPath)
244
245 res := second.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
246 if len(res) != 1 || !strings.Contains(res[0].Output, "persisted answer") {
247 t.Fatalf("restored wait = %+v, want persisted answer", res)
248 }
249 if got := second.WaitForSession(context.Background(), "session", nil, 1); len(got) != 0 {
250 t.Fatalf("wait without ids should ignore restored completed artifacts, got %+v", got)
251 }
252 if got := second.LeaseEvidenceForSession("session", j.ID); len(got.Receipts) != 0 {
253 t.Fatalf("mutation-free task restored mutation evidence: %+v", got)
254 }
255
256 next := second.StartForSession("session", "bash", "next", func(context.Context, io.Writer) (string, error) {
257 return "", nil
258 })
259 <-next.done
260 if next.ID == j.ID {
261 t.Fatalf("new job reused restored id %q", next.ID)
262 }
263 }
264
265 func TestRestoreRunningArtifactAsInterrupted(t *testing.T) {
266 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
267 dir := ArtifactDir(sessionPath)
268 metaPath := filepath.Join(dir, "task-1"+jobMetaExt)
269 if err := writeMeta(metaPath, artifactMeta{
270 ID: "task-1",
271 Kind: "task",
272 Status: Running,
273 StartedAt: time.Now().Add(-time.Minute).UnixMilli(),
274 ArtifactComplete: true,
275 }); err != nil {
276 t.Fatal(err)
277 }
278
279 m := NewManager(event.Discard, WithSessionOwnershipProbe(func(path string) bool {
280 return path == sessionPath
281 }))
282 defer m.Close()
283 m.SetActiveSessionPath("session", sessionPath)
284
285 if got := m.RunningForSession("session"); len(got) != 0 {
286 t.Fatalf("restored stale job remained live: %+v", got)
287 }
288 if m.KillForSession("session", "task-1") {
289 t.Fatal("restored interrupted job must not be killable")
290 }
291 result := m.WaitForSession(context.Background(), "session", []string{"task-1"}, 1)
292 if len(result) != 1 || result[0].Status != Interrupted {
293 t.Fatalf("restored result = %+v, want interrupted", result)
294 }
295
296 persisted, err := readMeta(metaPath)
297 if err != nil {
298 t.Fatal(err)
299 }
300 if persisted.Status != Interrupted || persisted.FinishedAt == 0 || persisted.ArtifactComplete {
301 t.Fatalf("persisted restored metadata = %+v", persisted)
302 }
303 }
304
305 func TestRestoreRunningArtifactDefersTombstoneWhenRepairWriteFails(t *testing.T) {
306 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
307 dir := ArtifactDir(sessionPath)
308 metaPath := filepath.Join(dir, "task-1"+jobMetaExt)
309 if err := writeMeta(metaPath, artifactMeta{
310 ID: "task-1",
311 Kind: "task",
312 Status: Running,
313 StartedAt: time.Now().Add(-time.Minute).UnixMilli(),
314 }); err != nil {
315 t.Fatal(err)
316 }
317
318 originalRepair := repairArtifactMeta
319 repairArtifactMeta = func(string, artifactMeta) error {
320 return errors.New("simulated repair failure")
321 }
322 t.Cleanup(func() { repairArtifactMeta = originalRepair })
323
324 sink := &recordingSink{}
325 m := NewManager(sink, WithSessionOwnershipProbe(func(path string) bool {
326 return path == sessionPath
327 }))
328 defer m.Close()
329 m.SetActiveSessionPath("session", sessionPath)
330
331 if result := m.WaitForSession(context.Background(), "session", []string{"task-1"}, 1); len(result) != 0 {
332 t.Fatalf("failed repair published an in-memory tombstone: %+v", result)
333 }
334 persisted, err := readMeta(metaPath)
335 if err != nil {
336 t.Fatal(err)
337 }
338 if persisted.Status != Running || persisted.FinishedAt != 0 {
339 t.Fatalf("failed repair changed durable metadata: %+v", persisted)
340 }
341 m.mu.Lock()
342 loaded := m.loaded["session"]
343 m.mu.Unlock()
344 if loaded {
345 t.Fatal("failed repair marked the session loaded and prevented retry")
346 }
347 sink.mu.Lock()
348 events := append([]event.Event(nil), sink.events...)
349 sink.mu.Unlock()
350 if len(events) != 1 || events[0].Kind != event.Notice || events[0].Level != event.LevelWarn ||
351 events[0].Text != "Background job recovery did not complete." ||
352 !strings.Contains(events[0].Detail, "task-1") || !strings.Contains(events[0].Detail, "simulated repair failure") {
353 t.Fatalf("repair failure notice = %+v", events)
354 }
355
356 repairArtifactMeta = originalRepair
357 m.SetActiveSessionPath("session", sessionPath)
358 result := m.WaitForSession(context.Background(), "session", []string{"task-1"}, 1)
359 if len(result) != 1 || result[0].Status != Interrupted {
360 t.Fatalf("retried repair result = %+v, want interrupted", result)
361 }
362 m.mu.Lock()
363 loaded = m.loaded["session"]
364 m.mu.Unlock()
365 if !loaded {
366 t.Fatal("successful retry did not mark the session loaded")
367 }
368 persisted, err = readMeta(metaPath)
369 if err != nil {
370 t.Fatal(err)
371 }
372 if persisted.Status != Interrupted || persisted.FinishedAt == 0 || persisted.ArtifactComplete {
373 t.Fatalf("retried repair metadata = %+v, want durable interrupted tombstone", persisted)
374 }
375 }
376
377 func TestRestoreRunningArtifactFromClosedOwnerAsInterrupted(t *testing.T) {
378 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
379 dir := ArtifactDir(sessionPath)
380 metaPath := filepath.Join(dir, "task-1"+jobMetaExt)
381 owner := NewManager(event.Discard)
382 ownerID := owner.ownerID
383 owner.Close()
384 if err := writeMeta(metaPath, artifactMeta{
385 ID: "task-1",
386 Kind: "task",
387 OwnerID: ownerID,
388 Status: Running,
389 StartedAt: time.Now().Add(-time.Minute).UnixMilli(),
390 }); err != nil {
391 t.Fatal(err)
392 }
393
394 restored := NewManager(event.Discard, WithSessionOwnershipProbe(func(path string) bool {
395 return path == sessionPath
396 }))
397 defer restored.Close()
398 restored.SetActiveSessionPath("session", sessionPath)
399 result := restored.WaitForSession(context.Background(), "session", []string{"task-1"}, 1)
400 if len(result) != 1 || result[0].Status != Interrupted {
401 t.Fatalf("restored result = %+v, want interrupted after the original owner closed", result)
402 }
403 }
404
405 func TestRestoreRunningArtifactWithoutSessionOwnershipDefersRepair(t *testing.T) {
406 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
407 metaPath := filepath.Join(ArtifactDir(sessionPath), "task-1"+jobMetaExt)
408 if err := writeMeta(metaPath, artifactMeta{
409 ID: "task-1",
410 Kind: "task",
411 Status: Running,
412 StartedAt: time.Now().Add(-time.Minute).UnixMilli(),
413 }); err != nil {
414 t.Fatal(err)
415 }
416
417 observer := NewManager(event.Discard)
418 observer.SetActiveSessionPath("session", sessionPath)
419 if result := observer.WaitForSession(context.Background(), "session", []string{"task-1"}, 1); len(result) != 0 {
420 observer.Close()
421 t.Fatalf("unowned observer published a running artifact: %+v", result)
422 }
423 meta, err := readMeta(metaPath)
424 if err != nil {
425 observer.Close()
426 t.Fatal(err)
427 }
428 if meta.Status != Running || meta.FinishedAt != 0 {
429 observer.Close()
430 t.Fatalf("unowned observer rewrote running metadata: %+v", meta)
431 }
432 observer.Close()
433
434 owner := NewManager(event.Discard, WithSessionOwnershipProbe(func(path string) bool {
435 return path == sessionPath
436 }))
437 defer owner.Close()
438 owner.SetActiveSessionPath("session", sessionPath)
439 result := owner.WaitForSession(context.Background(), "session", []string{"task-1"}, 1)
440 if len(result) != 1 || result[0].Status != Interrupted {
441 t.Fatalf("owned reload = %+v, want interrupted", result)
442 }
443 }
444
445 func TestRestoreDoesNotInterruptJobOwnedByLiveManager(t *testing.T) {
446 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
447 first := NewManager(event.Discard)
448 defer first.Close()
449 first.SetActiveSessionPath("session", sessionPath)
450
451 release := make(chan struct{})
452 job := first.StartForSession("session", "task", "running", func(context.Context, io.Writer) (string, error) {
453 <-release
454 return "done", nil
455 })
456 metaPath := filepath.Join(ArtifactDir(sessionPath), job.ID+jobMetaExt)
457
458 second := NewManager(event.Discard)
459 defer second.Close()
460 second.SetActiveSessionPath("session", sessionPath)
461
462 meta, err := readMeta(metaPath)
463 if err != nil {
464 close(release)
465 t.Fatal(err)
466 }
467 if meta.Status != Running {
468 close(release)
469 t.Fatalf("a replacement manager interrupted a still-live job: status=%q", meta.Status)
470 }
471 if got := second.RunningForSession("session"); len(got) != 0 {
472 close(release)
473 t.Fatalf("replacement manager published an unowned live job: %+v", got)
474 }
475
476 close(release)
477 <-job.done
478 second.SetActiveSessionPath("session", sessionPath)
479 result := second.WaitForSession(context.Background(), "session", []string{job.ID}, 1)
480 if len(result) != 1 || result[0].Status != Done {
481 t.Fatalf("replacement manager did not load the terminal artifact after owner completion: %+v", result)
482 }
483 }
484
485 func TestRunningJobArtifactMetadataIsIncomplete(t *testing.T) {
486 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
487 m := NewManager(event.Discard)
488 defer m.Close()
489 m.SetActiveSessionPath("session", sessionPath)
490
491 release := make(chan struct{})
492 job := m.StartForSession("session", "task", "running", func(context.Context, io.Writer) (string, error) {
493 <-release
494 return "done", nil
495 })
496 metaPath := filepath.Join(ArtifactDir(sessionPath), job.ID+jobMetaExt)
497 meta, err := readMeta(metaPath)
498 if err != nil {
499 close(release)
500 t.Fatal(err)
501 }
502 if meta.Status != Running || meta.FinishedAt != 0 || meta.ArtifactComplete {
503 close(release)
504 t.Fatalf("running metadata = %+v", meta)
505 }
506
507 close(release)
508 <-job.done
509 meta, err = readMeta(metaPath)
510 if err != nil {
511 t.Fatal(err)
512 }
513 if meta.Status != Done || meta.FinishedAt == 0 || !meta.ArtifactComplete {
514 t.Fatalf("terminal metadata = %+v", meta)
515 }
516 }
517
518 func TestTaskMutationEvidencePersistsWithoutSensitiveReceiptData(t *testing.T) {
519 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
520 first := NewManager(event.Discard)
521 first.SetActiveSessionPath("session", sessionPath)
522 const secret = "private-receipt-value-123456"
523 j := first.StartForSession("session", "task", "writer", func(ctx context.Context, _ io.Writer) (string, error) {
524 PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{
525 {
526 ToolName: "write_file",
527 Args: json.RawMessage(`{"path":"internal/agent/task.go","content":"` + secret + `"}`),
528 Success: true,
529 Write: true,
530 Mutation: true,
531 Paths: []string{"internal/agent/task.go"},
532 },
533 {
534 ToolName: "bash",
535 Success: true,
536 Command: "go test ./... --token=" + secret,
537 },
538 }})
539 return "persisted answer", nil
540 })
541 <-j.done
542 first.Close()
543
544 metaPath := filepath.Join(ArtifactDir(sessionPath), j.ID+jobMetaExt)
545 data, err := os.ReadFile(metaPath)
546 if err != nil {
547 t.Fatal(err)
548 }
549 text := string(data)
550 for _, leaked := range []string{secret, "content", "go test ./..."} {
551 if strings.Contains(text, leaked) {
552 t.Fatalf("job metadata persisted sensitive receipt data %q:\n%s", leaked, text)
553 }
554 }
555 for _, want := range []string{`"mutationEvidenceVersion": 1`, `"risk": "medium"`, `"internal/agent/task.go"`} {
556 if !strings.Contains(text, want) {
557 t.Fatalf("job metadata missing %q:\n%s", want, text)
558 }
559 }
560
561 second := NewManager(event.Discard)
562 defer second.Close()
563 second.SetActiveSessionPath("session", sessionPath)
564 summary := second.LeaseEvidenceForSession("session", j.ID)
565 if len(summary.Receipts) != 1 {
566 t.Fatalf("restored evidence = %+v, want one synthetic mutation", summary)
567 }
568 receipt := summary.Receipts[0]
569 if !receipt.Success || !receipt.Mutation || !receipt.Write || receipt.ToolName != recoveredBackgroundTaskToolName {
570 t.Fatalf("restored receipt = %+v, want successful recovered mutation", receipt)
571 }
572 if len(receipt.Paths) != 1 || filepath.ToSlash(receipt.Paths[0]) != "internal/agent/task.go" {
573 t.Fatalf("restored paths = %v, want internal/agent/task.go", receipt.Paths)
574 }
575 if len(receipt.Args) != 0 || receipt.Command != "" || receipt.Read {
576 t.Fatalf("restored receipt retained stale sign-off data: %+v", receipt)
577 }
578
579 ledger := evidence.NewLedger()
580 ledger.MergeChild(summary)
581 mutation, ok := ledger.LatestSuccessfulMutationIndex()
582 if !ok || ledger.HasSuccessfulReviewAfter(mutation) || ledger.HasSuccessfulVerificationCommand() {
583 t.Fatalf("restored evidence bypassed fresh review/verification: %+v", ledger.Summary())
584 }
585 if got := ledger.MutationRiskAfter(mutation); got != evidence.RiskMedium {
586 t.Fatalf("restored mutation risk = %s, want medium", got)
587 }
588 // Lease does not consume: the receipts stay available until the collecting
589 // turn commits. Only then is the persisted summary drained.
590 if again := second.LeaseEvidenceForSession("session", j.ID); len(again.Receipts) != 1 {
591 t.Fatalf("restored evidence not re-leasable before commit: %+v", again)
592 }
593 second.CommitEvidenceForSession("session", j.ID)
594 if afterCommit := second.LeaseEvidenceForSession("session", j.ID); len(afterCommit.Receipts) != 0 {
595 t.Fatalf("committed evidence still leasable: %+v", afterCommit)
596 }
597
598 // The commit drained the persisted copy too — a further restart must not
599 // offer the same mutation again.
600 third := NewManager(event.Discard)
601 defer third.Close()
602 third.SetActiveSessionPath("session", sessionPath)
603 if thirdLease := third.LeaseEvidenceForSession("session", j.ID); len(thirdLease.Receipts) != 0 {
604 t.Fatalf("committed evidence resurrected after restart: %+v", thirdLease)
605 }
606 }
607
608 func TestHighRiskTaskMutationEvidenceRestoresAsOpaque(t *testing.T) {
609 meta := artifactMeta{
610 Kind: "task",
611 MutationEvidenceVersion: mutationEvidenceVersion,
612 MutationEvidence: &artifactMutationEvidence{
613 Risk: string(evidence.RiskHigh),
614 Paths: []string{"ordinary-looking.go"},
615 },
616 }
617 summary := mutationEvidenceFromArtifact(meta)
618 if len(summary.Receipts) != 1 || len(summary.Receipts[0].Paths) != 0 {
619 t.Fatalf("high-risk restored evidence = %+v, want opaque mutation", summary)
620 }
621 ledger := evidence.NewLedger()
622 ledger.MergeChild(summary)
623 mutation, ok := ledger.LatestSuccessfulMutationIndex()
624 if !ok || ledger.MutationRiskAfter(mutation) != evidence.RiskHigh {
625 t.Fatalf("high-risk mutation was downgraded during recovery: %+v", ledger.Summary())
626 }
627 }
628
629 func TestLegacyTaskArtifactRecoversAsOpaqueHighRiskMutation(t *testing.T) {
630 // A pre-feature artifact (no mutationEvidenceVersion) proves only that the
631 // mutation state was never recorded — not that the task made no changes. A
632 // legacy background writer task collected after upgrade could carry real,
633 // unreviewed edits, so recovery must be conservative: an opaque RiskHigh
634 // mutation that forces fresh inspection and review rather than silently
635 // skipping it.
636 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
637 dir := ArtifactDir(sessionPath)
638 if err := os.MkdirAll(dir, 0o755); err != nil {
639 t.Fatal(err)
640 }
641 if err := os.WriteFile(filepath.Join(dir, "task-1"+jobLogExt), []byte("legacy answer"), 0o600); err != nil {
642 t.Fatal(err)
643 }
644 if err := writeMeta(filepath.Join(dir, "task-1"+jobMetaExt), artifactMeta{
645 ID: "task-1",
646 Kind: "task",
647 Status: Done,
648 ArtifactComplete: true,
649 LogPath: "task-1" + jobLogExt,
650 }); err != nil {
651 t.Fatal(err)
652 }
653
654 m := NewManager(event.Discard)
655 defer m.Close()
656 m.SetActiveSessionPath("session", sessionPath)
657 summary := m.LeaseEvidenceForSession("session", "task-1")
658 if len(summary.Receipts) != 1 || !summary.HasMutation() || len(summary.MutationPaths()) != 0 {
659 t.Fatalf("legacy task evidence = %+v, want one opaque mutation", summary)
660 }
661 ledger := evidence.NewLedger()
662 ledger.MergeChild(summary)
663 mutation, ok := ledger.LatestSuccessfulMutationIndex()
664 if !ok || ledger.MutationRiskAfter(mutation) != evidence.RiskHigh {
665 t.Fatalf("legacy task mutation was not recovered conservatively: %+v", ledger.Summary())
666 }
667 }
668
669 func TestFutureVersionTaskArtifactRecoversAsOpaqueHighRiskMutation(t *testing.T) {
670 // A meta written by a newer build (unknown non-zero version) may contain
671 // real evidence in a shape this build cannot parse: recover it as an opaque
672 // mutation so downgrade coexistence cannot skip review.
673 meta := artifactMeta{
674 Kind: "task",
675 MutationEvidenceVersion: mutationEvidenceVersion + 1,
676 }
677 summary := mutationEvidenceFromArtifact(meta)
678 if len(summary.Receipts) != 1 || !summary.HasMutation() || len(summary.MutationPaths()) != 0 {
679 t.Fatalf("future-version evidence = %+v, want one opaque mutation", summary)
680 }
681 ledger := evidence.NewLedger()
682 ledger.MergeChild(summary)
683 mutation, ok := ledger.LatestSuccessfulMutationIndex()
684 if !ok || ledger.MutationRiskAfter(mutation) != evidence.RiskHigh {
685 t.Fatalf("future-version mutation was not recovered conservatively: %+v", ledger.Summary())
686 }
687 }
688
689 func TestLeasedEvidenceResurrectsUntilCommitted(t *testing.T) {
690 // Collection is provisional. A lease that is never committed — the
691 // collecting turn was cancelled, errored, or the process exited before
692 // delivery — must leave the mutation recoverable after a restart, so a
693 // background change can never ship unreviewed. Only a commit drains the
694 // persisted copy.
695 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
696 first := NewManager(event.Discard)
697 first.SetActiveSessionPath("session", sessionPath)
698 j := first.StartForSession("session", "task", "writer", func(ctx context.Context, _ io.Writer) (string, error) {
699 PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
700 ToolName: "write_file", Success: true, Write: true, Mutation: true, Paths: []string{"changed.go"},
701 }}})
702 return "done", nil
703 })
704 <-j.done
705 // Lease without committing (the turn never delivered), then restart.
706 if leased := first.LeaseEvidenceForSession("session", j.ID); !leased.HasMutation() {
707 t.Fatalf("live lease = %+v, want the published mutation", leased)
708 }
709 first.Close()
710
711 data, err := os.ReadFile(filepath.Join(ArtifactDir(sessionPath), j.ID+jobMetaExt))
712 if err != nil {
713 t.Fatal(err)
714 }
715 if !strings.Contains(string(data), `"mutationEvidence"`) {
716 t.Fatalf("uncommitted lease drained the persisted mutation summary:\n%s", data)
717 }
718
719 second := NewManager(event.Discard)
720 defer second.Close()
721 second.SetActiveSessionPath("session", sessionPath)
722 if summary := second.LeaseEvidenceForSession("session", j.ID); !summary.HasMutation() {
723 t.Fatalf("uncommitted evidence lost after restart: %+v", summary)
724 }
725 // Committing after the restart drains it; a further restart offers nothing.
726 second.CommitEvidenceForSession("session", j.ID)
727 second.Close()
728 third := NewManager(event.Discard)
729 defer third.Close()
730 third.SetActiveSessionPath("session", sessionPath)
731 if summary := third.LeaseEvidenceForSession("session", j.ID); len(summary.Receipts) != 0 {
732 t.Fatalf("committed evidence resurrected after restart: %+v", summary)
733 }
734 }
735
736 func TestFinishDestroySessionPurgesOwnedJobs(t *testing.T) {
737 m := NewManager(event.Discard)
738 defer m.Close()
739
740 j := m.StartForSession("session", "task", "done", func(context.Context, io.Writer) (string, error) {
741 return "answer", nil
742 })
743 <-j.done
744
745 done := m.DestroySession("session")
746 if len(done) != 0 {
747 t.Fatalf("finished job should not need destroy wait, got %d handles", len(done))
748 }
749 m.FinishDestroySession("session")
750
751 if _, _, ok := m.OutputForSession("session", j.ID); ok {
752 t.Fatalf("destroyed session job %s should be purged", j.ID)
753 }
754 }
755
756 func TestSetActiveSessionPathMigratesRunningJobArtifacts(t *testing.T) {
757 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
758 m := NewManager(event.Discard)
759 defer m.Close()
760
761 wroteBefore := make(chan struct{})
762 release := make(chan struct{})
763 var releaseOnce sync.Once
764 defer releaseOnce.Do(func() { close(release) })
765 j := m.StartForSession("session", "bash", "migrate", func(_ context.Context, out io.Writer) (string, error) {
766 _, _ = io.WriteString(out, "before\n")
767 close(wroteBefore)
768 <-release
769 _, _ = io.WriteString(out, "after\n")
770 return "", nil
771 })
772 <-wroteBefore
773 j.mu.Lock()
774 oldPath := j.artifactPath
775 j.mu.Unlock()
776
777 m.SetActiveSessionPath("session", sessionPath)
778 j.mu.Lock()
779 gotPath := j.artifactPath
780 j.mu.Unlock()
781 if gotPath != oldPath {
782 t.Fatalf("running artifact path = %q, want unchanged %q before completion", gotPath, oldPath)
783 }
784
785 releaseOnce.Do(func() { close(release) })
786 <-j.done
787 j.mu.Lock()
788 donePath := j.artifactPath
789 j.mu.Unlock()
790 if !strings.HasPrefix(donePath, ArtifactDir(sessionPath)+string(filepath.Separator)) {
791 t.Fatalf("completed artifact path = %q, want under %q", donePath, ArtifactDir(sessionPath))
792 }
793 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
794 if len(res) != 1 || !strings.Contains(res[0].Output, "before\n") || !strings.Contains(res[0].Output, "after\n") {
795 t.Fatalf("wait after migration = %+v, want before and after output", res)
796 }
797 }
798
799 func TestArtifactFailureDoesNotFailSuccessfulJob(t *testing.T) {
800 dir := t.TempDir()
801 sessionPath := filepath.Join(dir, "session.jsonl")
802 if err := os.WriteFile(ArtifactDir(sessionPath), []byte("not a dir"), 0o644); err != nil {
803 t.Fatal(err)
804 }
805 m := NewManager(event.Discard)
806 defer m.Close()
807 m.SetActiveSessionPath("session", sessionPath)
808
809 j := m.StartForSession("session", "task", "artifact fail", func(context.Context, io.Writer) (string, error) {
810 return "successful result", nil
811 })
812 <-j.done
813
814 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
815 if len(res) != 1 || res[0].Status != Done {
816 t.Fatalf("wait = %+v, want one done result", res)
817 }
818 if !strings.Contains(res[0].Output, "successful result") || !strings.Contains(res[0].Output, "job artifact incomplete:") {
819 t.Fatalf("output = %q, want result and artifact warning", res[0].Output)
820 }
821 }
822
823 func TestMigrateArtifactDirFallsBackToCopyWhenRenameFails(t *testing.T) {
824 root := t.TempDir()
825 src := filepath.Join(root, "src")
826 dst := filepath.Join(root, "dst")
827 if err := os.MkdirAll(src, 0o755); err != nil {
828 t.Fatal(err)
829 }
830 if err := os.WriteFile(filepath.Join(src, "bash-1.log"), []byte("persisted output"), 0o644); err != nil {
831 t.Fatal(err)
832 }
833
834 oldRename := renamePath
835 renamePath = func(_, _ string) error {
836 return errors.New("forced rename failure")
837 }
838 t.Cleanup(func() { renamePath = oldRename })
839
840 if err := migrateArtifactDir(src, dst); err != nil {
841 t.Fatalf("migrateArtifactDir: %v", err)
842 }
843 got, err := os.ReadFile(filepath.Join(dst, "bash-1.log"))
844 if err != nil {
845 t.Fatalf("read migrated artifact: %v", err)
846 }
847 if string(got) != "persisted output" {
848 t.Fatalf("migrated artifact = %q, want persisted output", got)
849 }
850 if runtime.GOOS != "windows" {
851 assertPrivateArtifactMode(t, filepath.Join(dst, "bash-1.log"))
852 }
853 if _, err := os.Stat(filepath.Join(src, "bash-1.log")); !os.IsNotExist(err) {
854 t.Fatalf("source artifact should be removed after copy fallback, stat err = %v", err)
855 }
856 }
857
858 func TestSetActiveSessionPathAdoptsUnscopedTemporaryJobs(t *testing.T) {
859 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
860 m := NewManager(event.Discard)
861 defer m.Close()
862
863 j := m.StartForSession("", "task", "temporary", func(context.Context, io.Writer) (string, error) {
864 return "temporary answer", nil
865 })
866 <-j.done
867
868 m.SetActiveSessionPath("session", sessionPath)
869 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
870 if len(res) != 1 || !strings.Contains(res[0].Output, "temporary answer") {
871 t.Fatalf("adopted wait = %+v, want temporary answer", res)
872 }
873 if _, _, ok := m.OutputForSession("", j.ID); !ok {
874 t.Fatalf("legacy unscoped lookup should still find adopted job %s", j.ID)
875 }
876 if _, err := os.Stat(filepath.Join(ArtifactDir(sessionPath), j.ID+jobLogExt)); err != nil {
877 t.Fatalf("adopted artifact should be under persistent sidecar: %v", err)
878 }
879 }
880
881 func TestSetActiveSessionPathAdoptsUnscopedJobsOnMigrationFailure(t *testing.T) {
882 dir := t.TempDir()
883 sessionPath := filepath.Join(dir, "session.jsonl")
884 if err := os.WriteFile(ArtifactDir(sessionPath), []byte("not a dir"), 0o644); err != nil {
885 t.Fatal(err)
886 }
887 m := NewManager(event.Discard)
888 defer m.Close()
889
890 j := m.StartForSession("", "task", "temporary", func(context.Context, io.Writer) (string, error) {
891 return "temporary answer", nil
892 })
893 <-j.done
894
895 m.SetActiveSessionPath("session", sessionPath)
896
897 out, status, ok := m.OutputForSession("session", j.ID)
898 if !ok || status != Done {
899 t.Fatalf("scoped output ok/status = %v/%s, want true/done", ok, status)
900 }
901 if !strings.Contains(out, "temporary answer") || !strings.Contains(out, "job artifact incomplete: migration:") {
902 t.Fatalf("scoped output = %q, want answer and migration error", out)
903 }
904 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
905 if len(res) != 1 || !strings.Contains(res[0].Output, "job artifact incomplete: migration:") {
906 t.Fatalf("scoped wait = %+v, want migration error", res)
907 }
908 if note := m.DrainCompletedNoteForSession("session"); !strings.Contains(note, j.ID) {
909 t.Fatalf("adopted completion note = %q, want job id %s", note, j.ID)
910 }
911 }
912
913 func TestSetActiveSessionPathReportsMigrationFailure(t *testing.T) {
914 dir := t.TempDir()
915 sessionPath := filepath.Join(dir, "session.jsonl")
916 if err := os.WriteFile(ArtifactDir(sessionPath), []byte("not a dir"), 0o644); err != nil {
917 t.Fatal(err)
918 }
919 var noticesMu sync.Mutex
920 var notices []event.Event
921 m := NewManager(event.FuncSink(func(e event.Event) {
922 if e.Kind == event.Notice {
923 noticesMu.Lock()
924 notices = append(notices, e)
925 noticesMu.Unlock()
926 }
927 }))
928 defer m.Close()
929
930 j := m.StartForSession("session", "task", "migrate fail", func(context.Context, io.Writer) (string, error) {
931 return "answer", nil
932 })
933 m.SetActiveSessionPath("session", sessionPath)
934 <-j.done
935
936 res := m.WaitForSession(context.Background(), "session", []string{j.ID}, 1)
937 if len(res) != 1 || !strings.Contains(res[0].Output, "job artifact incomplete: migration:") {
938 t.Fatalf("wait after migration failure = %+v, want artifact error", res)
939 }
940 noticesMu.Lock()
941 capturedNotices := append([]event.Event(nil), notices...)
942 noticesMu.Unlock()
943 found := false
944 for _, notice := range capturedNotices {
945 if notice.Text == "Job artifact migration failed." && strings.Contains(notice.Detail, "job artifact migration failed") {
946 found = true
947 break
948 }
949 }
950 if !found {
951 t.Fatalf("migration failure notice not emitted, got %+v", capturedNotices)
952 }
953 }
954
955 func TestOutputReadsArtifactFromOffset(t *testing.T) {
956 dir := t.TempDir()
957 path := filepath.Join(dir, "bash-1.log")
958 prefix := strings.Repeat("x", 2*defaultTailBytes)
959 suffix := "new output\n"
960 if err := os.WriteFile(path, []byte(prefix+suffix), 0o644); err != nil {
961 t.Fatal(err)
962 }
963
964 m := NewManager(event.Discard)
965 defer m.Close()
966 j := &Job{
967 ID: "bash-1",
968 Kind: "bash",
969 SessionID: "session",
970 status: Running,
971 readOffset: int64(len(prefix)),
972 artifactPath: path,
973 done: make(chan struct{}),
974 }
975 m.jobs[jobKey("session", j.ID)] = j
976 m.order = append(m.order, jobKey("session", j.ID))
977
978 text, status, ok := m.OutputForSession("session", j.ID)
979 if !ok || status != Running {
980 t.Fatalf("OutputForSession ok/status = %v/%s, want true/running", ok, status)
981 }
982 if text != suffix {
983 t.Fatalf("OutputForSession text = %q, want %q", text, suffix)
984 }
985 if j.readOffset != int64(len(prefix)+len(suffix)) {
986 t.Fatalf("readOffset = %d, want %d", j.readOffset, len(prefix)+len(suffix))
987 }
988 }
989
990 func TestRestoredArtifactsAreScopedBySession(t *testing.T) {
991 root := t.TempDir()
992 pathA := filepath.Join(root, "a.jsonl")
993 pathB := filepath.Join(root, "b.jsonl")
994
995 first := NewManager(event.Discard)
996 first.SetActiveSessionPath("session-a", pathA)
997 jobA := first.StartForSession("session-a", "bash", "a", func(_ context.Context, out io.Writer) (string, error) {
998 _, _ = io.WriteString(out, "from-a")
999 return "", nil
1000 })
1001 <-jobA.done
1002 first.Close()
1003
1004 second := NewManager(event.Discard)
1005 second.SetActiveSessionPath("session-b", pathB)
1006 jobB := second.StartForSession("session-b", "bash", "b", func(_ context.Context, out io.Writer) (string, error) {
1007 _, _ = io.WriteString(out, "from-b")
1008 return "", nil
1009 })
1010 <-jobB.done
1011 second.Close()
1012
1013 if jobA.ID != "bash-1" || jobB.ID != "bash-1" {
1014 t.Fatalf("test setup expected duplicate ids, got %s and %s", jobA.ID, jobB.ID)
1015 }
1016
1017 m := NewManager(event.Discard)
1018 defer m.Close()
1019 m.SetActiveSessionPath("session-a", pathA)
1020 m.SetActiveSessionPath("session-b", pathB)
1021
1022 resA := m.WaitForSession(context.Background(), "session-a", []string{"bash-1"}, 1)
1023 if len(resA) != 1 || resA[0].Output != "from-a" {
1024 t.Fatalf("session-a wait = %+v, want from-a", resA)
1025 }
1026 resB := m.WaitForSession(context.Background(), "session-b", []string{"bash-1"}, 1)
1027 if len(resB) != 1 || resB[0].Output != "from-b" {
1028 t.Fatalf("session-b wait = %+v, want from-b", resB)
1029 }
1030
1031 next := m.StartForSession("session-b", "bash", "next", func(context.Context, io.Writer) (string, error) {
1032 return "", nil
1033 })
1034 <-next.done
1035 if next.ID == "bash-1" {
1036 t.Fatal("new job should not reuse restored bash-1")
1037 }
1038 }
1039
1039 lines GO