@os-eco/overstory-cli 0.9.4 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +47 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +54 -0
  26. package/src/commands/coordinator.test.ts +127 -0
  27. package/src/commands/coordinator.ts +203 -5
  28. package/src/commands/dashboard.test.ts +188 -0
  29. package/src/commands/dashboard.ts +13 -3
  30. package/src/commands/doctor.ts +3 -1
  31. package/src/commands/group.test.ts +94 -0
  32. package/src/commands/group.ts +49 -20
  33. package/src/commands/init.test.ts +8 -0
  34. package/src/commands/init.ts +8 -1
  35. package/src/commands/log.test.ts +56 -11
  36. package/src/commands/log.ts +134 -69
  37. package/src/commands/mail.test.ts +162 -0
  38. package/src/commands/mail.ts +64 -9
  39. package/src/commands/merge.test.ts +112 -1
  40. package/src/commands/merge.ts +17 -4
  41. package/src/commands/nudge.test.ts +351 -4
  42. package/src/commands/nudge.ts +356 -34
  43. package/src/commands/run.test.ts +43 -7
  44. package/src/commands/serve/build.test.ts +202 -0
  45. package/src/commands/serve/build.ts +206 -0
  46. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  47. package/src/commands/serve/coordinator-actions.ts +408 -0
  48. package/src/commands/serve/dev.test.ts +168 -0
  49. package/src/commands/serve/dev.ts +117 -0
  50. package/src/commands/serve/mail-actions.test.ts +312 -0
  51. package/src/commands/serve/mail-actions.ts +167 -0
  52. package/src/commands/serve/rest.test.ts +1323 -0
  53. package/src/commands/serve/rest.ts +708 -0
  54. package/src/commands/serve/static.ts +51 -0
  55. package/src/commands/serve/ws.test.ts +361 -0
  56. package/src/commands/serve/ws.ts +332 -0
  57. package/src/commands/serve.test.ts +459 -0
  58. package/src/commands/serve.ts +565 -0
  59. package/src/commands/sling.test.ts +73 -1
  60. package/src/commands/sling.ts +149 -64
  61. package/src/commands/status.test.ts +9 -0
  62. package/src/commands/status.ts +12 -4
  63. package/src/commands/stop.test.ts +174 -1
  64. package/src/commands/stop.ts +107 -8
  65. package/src/commands/watch.test.ts +43 -0
  66. package/src/commands/watch.ts +153 -28
  67. package/src/config.ts +23 -0
  68. package/src/doctor/consistency.test.ts +106 -0
  69. package/src/doctor/consistency.ts +48 -1
  70. package/src/doctor/serve.test.ts +95 -0
  71. package/src/doctor/serve.ts +86 -0
  72. package/src/doctor/types.ts +2 -1
  73. package/src/doctor/watchdog.ts +57 -1
  74. package/src/events/tailer.test.ts +234 -1
  75. package/src/events/tailer.ts +90 -0
  76. package/src/index.ts +53 -6
  77. package/src/json.ts +29 -0
  78. package/src/mail/client.ts +15 -2
  79. package/src/mail/store.test.ts +82 -0
  80. package/src/mail/store.ts +41 -4
  81. package/src/merge/lock.test.ts +149 -0
  82. package/src/merge/lock.ts +140 -0
  83. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  84. package/src/runtimes/claude.test.ts +791 -1
  85. package/src/runtimes/claude.ts +323 -1
  86. package/src/runtimes/connections.test.ts +141 -1
  87. package/src/runtimes/connections.ts +73 -4
  88. package/src/runtimes/headless-connection.test.ts +264 -0
  89. package/src/runtimes/headless-connection.ts +158 -0
  90. package/src/runtimes/types.ts +10 -0
  91. package/src/schema-consistency.test.ts +1 -0
  92. package/src/sessions/store.test.ts +390 -24
  93. package/src/sessions/store.ts +184 -19
  94. package/src/test-setup.test.ts +31 -0
  95. package/src/test-setup.ts +28 -0
  96. package/src/types.ts +56 -1
  97. package/src/utils/pid.test.ts +85 -1
  98. package/src/utils/pid.ts +86 -1
  99. package/src/utils/process-scan.test.ts +53 -0
  100. package/src/utils/process-scan.ts +76 -0
  101. package/src/watchdog/daemon.test.ts +1520 -411
  102. package/src/watchdog/daemon.ts +442 -83
  103. package/src/watchdog/health.test.ts +157 -0
  104. package/src/watchdog/health.ts +92 -25
  105. package/src/worktree/process.test.ts +71 -0
  106. package/src/worktree/process.ts +25 -5
  107. package/src/worktree/tmux.test.ts +3 -0
  108. package/src/worktree/tmux.ts +10 -3
  109. package/templates/CLAUDE.md.tmpl +19 -8
  110. package/templates/overlay.md.tmpl +3 -2
@@ -155,6 +155,46 @@ describe("upsert", () => {
155
155
  });
156
156
  });
157
157
 
158
+ // === claudeSessionId roundtrip via upsert ===
159
+
160
+ describe("claudeSessionId upsert roundtrip", () => {
161
+ test("undefined claudeSessionId leaves column null and field absent on roundtrip", () => {
162
+ store.upsert(makeSession());
163
+ const result = store.getByName("test-agent");
164
+ expect(result).not.toBeNull();
165
+ expect(Object.hasOwn(result ?? {}, "claudeSessionId")).toBe(false);
166
+ });
167
+
168
+ test("claudeSessionId roundtrips correctly when set", () => {
169
+ store.upsert(makeSession({ claudeSessionId: "sess-roundtrip-xyz" }));
170
+ const result = store.getByName("test-agent");
171
+ expect(result?.claudeSessionId).toBe("sess-roundtrip-xyz");
172
+ });
173
+ });
174
+
175
+ // === updateClaudeSessionId ===
176
+
177
+ describe("updateClaudeSessionId", () => {
178
+ test("sets claude_session_id for an existing session; getByName returns it", () => {
179
+ store.upsert(makeSession());
180
+ store.updateClaudeSessionId("test-agent", "sess-pin-001");
181
+ const result = store.getByName("test-agent");
182
+ expect(result?.claudeSessionId).toBe("sess-pin-001");
183
+ });
184
+
185
+ test("calling twice with the same value is idempotent", () => {
186
+ store.upsert(makeSession());
187
+ store.updateClaudeSessionId("test-agent", "sess-idempotent");
188
+ store.updateClaudeSessionId("test-agent", "sess-idempotent");
189
+ const result = store.getByName("test-agent");
190
+ expect(result?.claudeSessionId).toBe("sess-idempotent");
191
+ });
192
+
193
+ test("calling for an unknown agent is a no-op (does not throw)", () => {
194
+ expect(() => store.updateClaudeSessionId("nonexistent", "sess-noop")).not.toThrow();
195
+ });
196
+ });
197
+
158
198
  // === updateTranscriptPath ===
159
199
 
160
200
  describe("updateTranscriptPath", () => {
@@ -342,6 +382,308 @@ describe("updateState", () => {
342
382
  });
343
383
  });
344
384
 
385
+ // === tryTransitionState (matrix-guarded CAS) ===
386
+
387
+ describe("tryTransitionState", () => {
388
+ test("returns not_found for an unknown agent", () => {
389
+ const outcome = store.tryTransitionState("nonexistent", "completed");
390
+ expect(outcome.ok).toBe(false);
391
+ if (!outcome.ok) {
392
+ expect(outcome.reason).toBe("not_found");
393
+ expect(outcome.attempted).toBe("completed");
394
+ }
395
+ });
396
+
397
+ test("booting → working lands and returns prev/next", () => {
398
+ store.upsert(makeSession({ state: "booting" }));
399
+ const outcome = store.tryTransitionState("test-agent", "working");
400
+ expect(outcome.ok).toBe(true);
401
+ if (outcome.ok) {
402
+ expect(outcome.prev).toBe("booting");
403
+ expect(outcome.next).toBe("working");
404
+ }
405
+ expect(store.getByName("test-agent")?.state).toBe("working");
406
+ });
407
+
408
+ test("working → completed lands (clean turn-end settle)", () => {
409
+ store.upsert(makeSession({ state: "working" }));
410
+ const outcome = store.tryTransitionState("test-agent", "completed");
411
+ expect(outcome.ok).toBe(true);
412
+ expect(store.getByName("test-agent")?.state).toBe("completed");
413
+ });
414
+
415
+ test("working → zombie lands (watchdog terminate)", () => {
416
+ store.upsert(makeSession({ state: "working" }));
417
+ const outcome = store.tryTransitionState("test-agent", "zombie");
418
+ expect(outcome.ok).toBe(true);
419
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
420
+ });
421
+
422
+ test("zombie → completed lands (ov stop cleanup of zombie)", () => {
423
+ store.upsert(makeSession({ state: "zombie" }));
424
+ const outcome = store.tryTransitionState("test-agent", "completed");
425
+ expect(outcome.ok).toBe(true);
426
+ expect(store.getByName("test-agent")?.state).toBe("completed");
427
+ });
428
+
429
+ test("completed → zombie is rejected (sticky completed)", () => {
430
+ store.upsert(makeSession({ state: "completed" }));
431
+ const outcome = store.tryTransitionState("test-agent", "zombie");
432
+ expect(outcome.ok).toBe(false);
433
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
434
+ expect(outcome.prev).toBe("completed");
435
+ expect(outcome.attempted).toBe("zombie");
436
+ }
437
+ expect(store.getByName("test-agent")?.state).toBe("completed");
438
+ });
439
+
440
+ test("completed → working is rejected (turn-runner cannot revive completed)", () => {
441
+ store.upsert(makeSession({ state: "completed" }));
442
+ const outcome = store.tryTransitionState("test-agent", "working");
443
+ expect(outcome.ok).toBe(false);
444
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
445
+ expect(outcome.prev).toBe("completed");
446
+ }
447
+ expect(store.getByName("test-agent")?.state).toBe("completed");
448
+ });
449
+
450
+ test("zombie → working is rejected (PreToolUse hook cannot revive zombie)", () => {
451
+ store.upsert(makeSession({ state: "zombie" }));
452
+ const outcome = store.tryTransitionState("test-agent", "working");
453
+ expect(outcome.ok).toBe(false);
454
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
455
+ expect(outcome.prev).toBe("zombie");
456
+ expect(outcome.attempted).toBe("working");
457
+ }
458
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
459
+ });
460
+
461
+ test("idempotent same-state transitions land (working → working)", () => {
462
+ store.upsert(makeSession({ state: "working" }));
463
+ const outcome = store.tryTransitionState("test-agent", "working");
464
+ expect(outcome.ok).toBe(true);
465
+ expect(store.getByName("test-agent")?.state).toBe("working");
466
+ });
467
+
468
+ test("idempotent completed → completed is allowed", () => {
469
+ store.upsert(makeSession({ state: "completed" }));
470
+ const outcome = store.tryTransitionState("test-agent", "completed");
471
+ expect(outcome.ok).toBe(true);
472
+ expect(store.getByName("test-agent")?.state).toBe("completed");
473
+ });
474
+
475
+ test("idempotent zombie → zombie is allowed", () => {
476
+ store.upsert(makeSession({ state: "zombie" }));
477
+ const outcome = store.tryTransitionState("test-agent", "zombie");
478
+ expect(outcome.ok).toBe(true);
479
+ });
480
+
481
+ test("nothing transitions into booting (matrix has no allowed predecessors)", () => {
482
+ store.upsert(makeSession({ state: "working" }));
483
+ const outcome = store.tryTransitionState("test-agent", "booting");
484
+ expect(outcome.ok).toBe(false);
485
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
486
+ expect(outcome.prev).toBe("working");
487
+ }
488
+ });
489
+
490
+ test("stalled → working is allowed (recovery)", () => {
491
+ store.upsert(makeSession({ state: "stalled" }));
492
+ const outcome = store.tryTransitionState("test-agent", "working");
493
+ expect(outcome.ok).toBe(true);
494
+ expect(store.getByName("test-agent")?.state).toBe("working");
495
+ });
496
+
497
+ test("race scenario: ov stop wins, late watchdog zombie write rejected", () => {
498
+ // Models the overstory-a993 symptom directly: ov stop completes the
499
+ // agent first; a stale watchdog tick then tries to mark it zombie.
500
+ store.upsert(makeSession({ state: "working" }));
501
+
502
+ const stopOutcome = store.tryTransitionState("test-agent", "completed");
503
+ expect(stopOutcome.ok).toBe(true);
504
+
505
+ const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
506
+ expect(watchdogOutcome.ok).toBe(false);
507
+ if (!watchdogOutcome.ok && watchdogOutcome.reason === "illegal_transition") {
508
+ expect(watchdogOutcome.prev).toBe("completed");
509
+ }
510
+ expect(store.getByName("test-agent")?.state).toBe("completed");
511
+ });
512
+
513
+ test("race scenario: watchdog wins zombie, late turn-runner working write rejected", () => {
514
+ // Watchdog observes the agent dead and marks zombie; meanwhile a
515
+ // turn-runner whose initialState was 'working' tries to settle to
516
+ // 'working' at end of turn. The settle must NOT undo the zombie call.
517
+ store.upsert(makeSession({ state: "working" }));
518
+
519
+ const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
520
+ expect(watchdogOutcome.ok).toBe(true);
521
+
522
+ const turnRunnerOutcome = store.tryTransitionState("test-agent", "working");
523
+ expect(turnRunnerOutcome.ok).toBe(false);
524
+ if (!turnRunnerOutcome.ok && turnRunnerOutcome.reason === "illegal_transition") {
525
+ expect(turnRunnerOutcome.prev).toBe("zombie");
526
+ }
527
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
528
+ });
529
+
530
+ test("race scenario: ov stop promotes a zombie to completed", () => {
531
+ // Inverse of the previous race: watchdog already marked zombie, then
532
+ // the operator runs `ov stop` to clean up. The cleanup must succeed.
533
+ store.upsert(makeSession({ state: "zombie" }));
534
+
535
+ const stopOutcome = store.tryTransitionState("test-agent", "completed");
536
+ expect(stopOutcome.ok).toBe(true);
537
+ expect(store.getByName("test-agent")?.state).toBe("completed");
538
+ });
539
+
540
+ test("concurrent CAS: two writers try the same target, only one observes ok", () => {
541
+ // Simulates the SQL CAS exclusivity: two writers race against the same
542
+ // row with the same target. The DB serializes the writes; only the one
543
+ // that finds the row in an allowed-from state when the CAS executes
544
+ // reports ok=true. We can't truly run them in parallel from a single
545
+ // thread, but we can prove the invariant: after BOTH calls, the row
546
+ // is in the target state and exactly one call returned ok=true.
547
+ store.upsert(makeSession({ state: "working" }));
548
+
549
+ const a = store.tryTransitionState("test-agent", "completed");
550
+ const b = store.tryTransitionState("test-agent", "completed");
551
+
552
+ // First call lands (working → completed). Second is idempotent
553
+ // (completed → completed is in the matrix), so both report ok.
554
+ expect(a.ok).toBe(true);
555
+ expect(b.ok).toBe(true);
556
+ expect(store.getByName("test-agent")?.state).toBe("completed");
557
+ });
558
+
559
+ test("concurrent CAS: conflicting targets — second writer rejected", () => {
560
+ // First writer lands working → completed; second writer attempts
561
+ // working → zombie but the row is now completed → REJECTED.
562
+ store.upsert(makeSession({ state: "working" }));
563
+
564
+ const stop = store.tryTransitionState("test-agent", "completed");
565
+ expect(stop.ok).toBe(true);
566
+
567
+ const watchdog = store.tryTransitionState("test-agent", "zombie");
568
+ expect(watchdog.ok).toBe(false);
569
+ if (!watchdog.ok && watchdog.reason === "illegal_transition") {
570
+ expect(watchdog.prev).toBe("completed");
571
+ }
572
+ });
573
+ });
574
+
575
+ // === tmux_session clearing on terminal transitions (overstory-14c0) ===
576
+ //
577
+ // The tmux session is torn down by ov stop / watchdog / coordinator cleanup
578
+ // before the state lands at completed/zombie. The stored tmux_session string
579
+ // would otherwise stay forever, surfacing dead session names in the agents
580
+ // view of `ov status`. Both updateState and tryTransitionState must clear it.
581
+
582
+ describe("tmux_session clearing on terminal transitions", () => {
583
+ const tmux = "overstory-test-agent";
584
+
585
+ describe("updateState", () => {
586
+ test("clears tmux_session when transitioning to completed", () => {
587
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
588
+ store.updateState("test-agent", "completed");
589
+ const result = store.getByName("test-agent");
590
+ expect(result?.state).toBe("completed");
591
+ expect(result?.tmuxSession).toBe("");
592
+ });
593
+
594
+ test("clears tmux_session when transitioning to zombie", () => {
595
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
596
+ store.updateState("test-agent", "zombie");
597
+ const result = store.getByName("test-agent");
598
+ expect(result?.state).toBe("zombie");
599
+ expect(result?.tmuxSession).toBe("");
600
+ });
601
+
602
+ test("preserves tmux_session when transitioning to a non-terminal state", () => {
603
+ store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
604
+ store.updateState("test-agent", "working");
605
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
606
+
607
+ store.updateState("test-agent", "stalled");
608
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
609
+ });
610
+
611
+ test("idempotent terminal write keeps tmux_session cleared", () => {
612
+ store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
613
+ store.updateState("test-agent", "completed");
614
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
615
+ });
616
+ });
617
+
618
+ describe("tryTransitionState", () => {
619
+ test("clears tmux_session on working → completed", () => {
620
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
621
+ const outcome = store.tryTransitionState("test-agent", "completed");
622
+ expect(outcome.ok).toBe(true);
623
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
624
+ });
625
+
626
+ test("clears tmux_session on working → zombie", () => {
627
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
628
+ const outcome = store.tryTransitionState("test-agent", "zombie");
629
+ expect(outcome.ok).toBe(true);
630
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
631
+ });
632
+
633
+ test("clears tmux_session on zombie → completed (ov stop cleanup of zombie)", () => {
634
+ // Zombie may still hold a tmux_session if the watchdog landed before
635
+ // any cleanup pass; the subsequent ov stop must wipe it.
636
+ store.upsert(makeSession({ state: "zombie", tmuxSession: tmux }));
637
+ const outcome = store.tryTransitionState("test-agent", "completed");
638
+ expect(outcome.ok).toBe(true);
639
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
640
+ });
641
+
642
+ test("clears tmux_session on stalled → zombie (watchdog terminate)", () => {
643
+ store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
644
+ const outcome = store.tryTransitionState("test-agent", "zombie");
645
+ expect(outcome.ok).toBe(true);
646
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
647
+ });
648
+
649
+ test("preserves tmux_session on non-terminal targets (booting → working)", () => {
650
+ store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
651
+ const outcome = store.tryTransitionState("test-agent", "working");
652
+ expect(outcome.ok).toBe(true);
653
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
654
+ });
655
+
656
+ test("preserves tmux_session on non-terminal targets (stalled → working)", () => {
657
+ store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
658
+ const outcome = store.tryTransitionState("test-agent", "working");
659
+ expect(outcome.ok).toBe(true);
660
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
661
+ });
662
+
663
+ test("does not clear tmux_session when CAS rejects an illegal terminal transition", () => {
664
+ // completed → zombie is rejected. The row must remain untouched —
665
+ // in particular, tmux_session must keep whatever the row already
666
+ // held (an empty string here, since the prior completed write
667
+ // cleared it).
668
+ store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
669
+ const outcome = store.tryTransitionState("test-agent", "zombie");
670
+ expect(outcome.ok).toBe(false);
671
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
672
+ });
673
+
674
+ test("does not clear tmux_session when CAS rejects against a live row", () => {
675
+ // nothing transitions into booting; a working row keeps both state
676
+ // and tmux_session.
677
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
678
+ const outcome = store.tryTransitionState("test-agent", "booting");
679
+ expect(outcome.ok).toBe(false);
680
+ const result = store.getByName("test-agent");
681
+ expect(result?.state).toBe("working");
682
+ expect(result?.tmuxSession).toBe(tmux);
683
+ });
684
+ });
685
+ });
686
+
345
687
  // === updateLastActivity ===
346
688
 
347
689
  describe("updateLastActivity", () => {
@@ -677,11 +1019,29 @@ describe("RunStore", () => {
677
1019
  expect(result).toBeNull();
678
1020
  });
679
1021
 
680
- test("creates a run with explicit agentCount", () => {
1022
+ test("agent count is derived from sessions in the same run", () => {
681
1023
  runStore.createRun(makeRun({ agentCount: 5 }));
1024
+ // `agentCount` no longer reflects the column; it's a live count of
1025
+ // sessions whose `run_id` matches. With no sessions, count is 0.
1026
+ expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(0);
682
1027
 
683
- const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
684
- expect(result?.agentCount).toBe(5);
1028
+ store.upsert(
1029
+ makeSession({
1030
+ id: "s-1",
1031
+ agentName: "a-1",
1032
+ runId: "run-2026-02-13T10:00:00.000Z",
1033
+ }),
1034
+ );
1035
+ expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(1);
1036
+
1037
+ store.upsert(
1038
+ makeSession({
1039
+ id: "s-2",
1040
+ agentName: "a-2",
1041
+ runId: "run-2026-02-13T10:00:00.000Z",
1042
+ }),
1043
+ );
1044
+ expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(2);
685
1045
  });
686
1046
 
687
1047
  test("creates a run with null coordinatorSessionId", () => {
@@ -840,33 +1200,24 @@ describe("RunStore", () => {
840
1200
  });
841
1201
 
842
1202
  // === incrementAgentCount ===
1203
+ //
1204
+ // Retained as a no-op for API compatibility. `agentCount` is now derived
1205
+ // from the sessions table at read time (overstory-8e69), so calls have no
1206
+ // effect on what `getRun` returns.
843
1207
 
844
1208
  describe("incrementAgentCount", () => {
845
- test("increments agent count by 1", () => {
1209
+ test("is a no-op — agent count comes from sessions, not the column", () => {
846
1210
  runStore.createRun(makeRun());
847
1211
 
848
1212
  runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
849
- let result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
850
- expect(result?.agentCount).toBe(1);
851
-
852
1213
  runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
853
- result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
854
- expect(result?.agentCount).toBe(2);
855
- });
856
1214
 
857
- test("is a no-op for nonexistent run (does not throw)", () => {
858
- // Should not throw
859
- runStore.incrementAgentCount("nonexistent-run");
1215
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
1216
+ expect(result?.agentCount).toBe(0);
860
1217
  });
861
1218
 
862
- test("does not affect other run fields", () => {
863
- runStore.createRun(makeRun());
864
- runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
865
-
866
- const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
867
- expect(result?.status).toBe("active");
868
- expect(result?.completedAt).toBeNull();
869
- expect(result?.coordinatorSessionId).toBe("coord-session-001");
1219
+ test("is safe to call for a nonexistent run (does not throw)", () => {
1220
+ runStore.incrementAgentCount("nonexistent-run");
870
1221
  });
871
1222
  });
872
1223
 
@@ -904,9 +1255,15 @@ describe("RunStore", () => {
904
1255
 
905
1256
  test("preserves agent count when completing", () => {
906
1257
  runStore.createRun(makeRun());
907
- runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
908
- runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
909
- runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
1258
+ for (let i = 0; i < 3; i++) {
1259
+ store.upsert(
1260
+ makeSession({
1261
+ id: `s-${i}`,
1262
+ agentName: `a-${i}`,
1263
+ runId: "run-2026-02-13T10:00:00.000Z",
1264
+ }),
1265
+ );
1266
+ }
910
1267
 
911
1268
  runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
912
1269
 
@@ -1106,6 +1463,15 @@ describe("RunStore", () => {
1106
1463
  };
1107
1464
 
1108
1465
  runStore.createRun(run);
1466
+ for (let i = 0; i < 7; i++) {
1467
+ store.upsert(
1468
+ makeSession({
1469
+ id: `s-rt-${i}`,
1470
+ agentName: `a-rt-${i}`,
1471
+ runId: "run-roundtrip-test",
1472
+ }),
1473
+ );
1474
+ }
1109
1475
  const result = runStore.getRun("run-roundtrip-test");
1110
1476
 
1111
1477
  expect(result).not.toBeNull();