@os-eco/overstory-cli 0.9.4 → 0.11.0

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 (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  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 +219 -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/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -133,11 +133,17 @@ describe("upsert", () => {
133
133
  expect(result?.stalledSince).toBeNull();
134
134
  });
135
135
 
136
- test("rejects invalid state values via CHECK constraint", () => {
136
+ test("accepts arbitrary state strings (CHECK relaxed in overstory-3087)", () => {
137
+ // The inline CHECK on `state` was dropped (overstory-3087): the
138
+ // TypeScript `AgentState` union enforces values at the writer
139
+ // boundary, so the SQL constraint became a maintenance tax that had
140
+ // to be rebuilt on every union extension. Verify the schema no
141
+ // longer throws when an out-of-union value reaches it. Real callers
142
+ // type their writes through the union and cannot land here.
137
143
  const session = makeSession();
138
- // Force an invalid state to test the CHECK constraint
139
144
  const badSession = { ...session, state: "invalid" as AgentState };
140
- expect(() => store.upsert(badSession)).toThrow();
145
+ expect(() => store.upsert(badSession)).not.toThrow();
146
+ expect(store.getByName("test-agent")?.state).toBe("invalid" as AgentState);
141
147
  });
142
148
 
143
149
  test("handles null transcriptPath", () => {
@@ -155,6 +161,46 @@ describe("upsert", () => {
155
161
  });
156
162
  });
157
163
 
164
+ // === claudeSessionId roundtrip via upsert ===
165
+
166
+ describe("claudeSessionId upsert roundtrip", () => {
167
+ test("undefined claudeSessionId leaves column null and field absent on roundtrip", () => {
168
+ store.upsert(makeSession());
169
+ const result = store.getByName("test-agent");
170
+ expect(result).not.toBeNull();
171
+ expect(Object.hasOwn(result ?? {}, "claudeSessionId")).toBe(false);
172
+ });
173
+
174
+ test("claudeSessionId roundtrips correctly when set", () => {
175
+ store.upsert(makeSession({ claudeSessionId: "sess-roundtrip-xyz" }));
176
+ const result = store.getByName("test-agent");
177
+ expect(result?.claudeSessionId).toBe("sess-roundtrip-xyz");
178
+ });
179
+ });
180
+
181
+ // === updateClaudeSessionId ===
182
+
183
+ describe("updateClaudeSessionId", () => {
184
+ test("sets claude_session_id for an existing session; getByName returns it", () => {
185
+ store.upsert(makeSession());
186
+ store.updateClaudeSessionId("test-agent", "sess-pin-001");
187
+ const result = store.getByName("test-agent");
188
+ expect(result?.claudeSessionId).toBe("sess-pin-001");
189
+ });
190
+
191
+ test("calling twice with the same value is idempotent", () => {
192
+ store.upsert(makeSession());
193
+ store.updateClaudeSessionId("test-agent", "sess-idempotent");
194
+ store.updateClaudeSessionId("test-agent", "sess-idempotent");
195
+ const result = store.getByName("test-agent");
196
+ expect(result?.claudeSessionId).toBe("sess-idempotent");
197
+ });
198
+
199
+ test("calling for an unknown agent is a no-op (does not throw)", () => {
200
+ expect(() => store.updateClaudeSessionId("nonexistent", "sess-noop")).not.toThrow();
201
+ });
202
+ });
203
+
158
204
  // === updateTranscriptPath ===
159
205
 
160
206
  describe("updateTranscriptPath", () => {
@@ -336,9 +382,567 @@ describe("updateState", () => {
336
382
  store.updateState("nonexistent", "completed");
337
383
  });
338
384
 
339
- test("rejects invalid state via CHECK constraint", () => {
385
+ test("accepts arbitrary state strings (CHECK relaxed in overstory-3087)", () => {
386
+ // Same rationale as the upsert test: the TypeScript `AgentState`
387
+ // union is the authoritative gate; the SQL CHECK was dropped to
388
+ // avoid the rebuild tax on every union extension.
340
389
  store.upsert(makeSession());
341
- expect(() => store.updateState("test-agent", "invalid" as AgentState)).toThrow();
390
+ expect(() => store.updateState("test-agent", "invalid" as AgentState)).not.toThrow();
391
+ expect(store.getByName("test-agent")?.state).toBe("invalid" as AgentState);
392
+ });
393
+ });
394
+
395
+ // === tryTransitionState (matrix-guarded CAS) ===
396
+
397
+ describe("tryTransitionState", () => {
398
+ test("returns not_found for an unknown agent", () => {
399
+ const outcome = store.tryTransitionState("nonexistent", "completed");
400
+ expect(outcome.ok).toBe(false);
401
+ if (!outcome.ok) {
402
+ expect(outcome.reason).toBe("not_found");
403
+ expect(outcome.attempted).toBe("completed");
404
+ }
405
+ });
406
+
407
+ test("booting → working lands and returns prev/next", () => {
408
+ store.upsert(makeSession({ state: "booting" }));
409
+ const outcome = store.tryTransitionState("test-agent", "working");
410
+ expect(outcome.ok).toBe(true);
411
+ if (outcome.ok) {
412
+ expect(outcome.prev).toBe("booting");
413
+ expect(outcome.next).toBe("working");
414
+ }
415
+ expect(store.getByName("test-agent")?.state).toBe("working");
416
+ });
417
+
418
+ test("working → completed lands (clean turn-end settle)", () => {
419
+ store.upsert(makeSession({ state: "working" }));
420
+ const outcome = store.tryTransitionState("test-agent", "completed");
421
+ expect(outcome.ok).toBe(true);
422
+ expect(store.getByName("test-agent")?.state).toBe("completed");
423
+ });
424
+
425
+ test("working → zombie lands (watchdog terminate)", () => {
426
+ store.upsert(makeSession({ state: "working" }));
427
+ const outcome = store.tryTransitionState("test-agent", "zombie");
428
+ expect(outcome.ok).toBe(true);
429
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
430
+ });
431
+
432
+ test("zombie → completed lands (ov stop cleanup of zombie)", () => {
433
+ store.upsert(makeSession({ state: "zombie" }));
434
+ const outcome = store.tryTransitionState("test-agent", "completed");
435
+ expect(outcome.ok).toBe(true);
436
+ expect(store.getByName("test-agent")?.state).toBe("completed");
437
+ });
438
+
439
+ test("completed → zombie is rejected (sticky completed)", () => {
440
+ store.upsert(makeSession({ state: "completed" }));
441
+ const outcome = store.tryTransitionState("test-agent", "zombie");
442
+ expect(outcome.ok).toBe(false);
443
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
444
+ expect(outcome.prev).toBe("completed");
445
+ expect(outcome.attempted).toBe("zombie");
446
+ }
447
+ expect(store.getByName("test-agent")?.state).toBe("completed");
448
+ });
449
+
450
+ test("completed → working is rejected (turn-runner cannot revive completed)", () => {
451
+ store.upsert(makeSession({ state: "completed" }));
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("completed");
456
+ }
457
+ expect(store.getByName("test-agent")?.state).toBe("completed");
458
+ });
459
+
460
+ test("zombie → working is rejected (PreToolUse hook cannot revive zombie)", () => {
461
+ store.upsert(makeSession({ state: "zombie" }));
462
+ const outcome = store.tryTransitionState("test-agent", "working");
463
+ expect(outcome.ok).toBe(false);
464
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
465
+ expect(outcome.prev).toBe("zombie");
466
+ expect(outcome.attempted).toBe("working");
467
+ }
468
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
469
+ });
470
+
471
+ test("idempotent same-state transitions land (working → working)", () => {
472
+ store.upsert(makeSession({ state: "working" }));
473
+ const outcome = store.tryTransitionState("test-agent", "working");
474
+ expect(outcome.ok).toBe(true);
475
+ expect(store.getByName("test-agent")?.state).toBe("working");
476
+ });
477
+
478
+ test("idempotent completed → completed is allowed", () => {
479
+ store.upsert(makeSession({ state: "completed" }));
480
+ const outcome = store.tryTransitionState("test-agent", "completed");
481
+ expect(outcome.ok).toBe(true);
482
+ expect(store.getByName("test-agent")?.state).toBe("completed");
483
+ });
484
+
485
+ test("idempotent zombie → zombie is allowed", () => {
486
+ store.upsert(makeSession({ state: "zombie" }));
487
+ const outcome = store.tryTransitionState("test-agent", "zombie");
488
+ expect(outcome.ok).toBe(true);
489
+ });
490
+
491
+ test("nothing transitions into booting (matrix has no allowed predecessors)", () => {
492
+ store.upsert(makeSession({ state: "working" }));
493
+ const outcome = store.tryTransitionState("test-agent", "booting");
494
+ expect(outcome.ok).toBe(false);
495
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
496
+ expect(outcome.prev).toBe("working");
497
+ }
498
+ });
499
+
500
+ test("stalled → working is allowed (recovery)", () => {
501
+ store.upsert(makeSession({ state: "stalled" }));
502
+ const outcome = store.tryTransitionState("test-agent", "working");
503
+ expect(outcome.ok).toBe(true);
504
+ expect(store.getByName("test-agent")?.state).toBe("working");
505
+ });
506
+
507
+ test("race scenario: ov stop wins, late watchdog zombie write rejected", () => {
508
+ // Models the overstory-a993 symptom directly: ov stop completes the
509
+ // agent first; a stale watchdog tick then tries to mark it zombie.
510
+ store.upsert(makeSession({ state: "working" }));
511
+
512
+ const stopOutcome = store.tryTransitionState("test-agent", "completed");
513
+ expect(stopOutcome.ok).toBe(true);
514
+
515
+ const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
516
+ expect(watchdogOutcome.ok).toBe(false);
517
+ if (!watchdogOutcome.ok && watchdogOutcome.reason === "illegal_transition") {
518
+ expect(watchdogOutcome.prev).toBe("completed");
519
+ }
520
+ expect(store.getByName("test-agent")?.state).toBe("completed");
521
+ });
522
+
523
+ test("race scenario: watchdog wins zombie, late turn-runner working write rejected", () => {
524
+ // Watchdog observes the agent dead and marks zombie; meanwhile a
525
+ // turn-runner whose initialState was 'working' tries to settle to
526
+ // 'working' at end of turn. The settle must NOT undo the zombie call.
527
+ store.upsert(makeSession({ state: "working" }));
528
+
529
+ const watchdogOutcome = store.tryTransitionState("test-agent", "zombie");
530
+ expect(watchdogOutcome.ok).toBe(true);
531
+
532
+ const turnRunnerOutcome = store.tryTransitionState("test-agent", "working");
533
+ expect(turnRunnerOutcome.ok).toBe(false);
534
+ if (!turnRunnerOutcome.ok && turnRunnerOutcome.reason === "illegal_transition") {
535
+ expect(turnRunnerOutcome.prev).toBe("zombie");
536
+ }
537
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
538
+ });
539
+
540
+ test("race scenario: ov stop promotes a zombie to completed", () => {
541
+ // Inverse of the previous race: watchdog already marked zombie, then
542
+ // the operator runs `ov stop` to clean up. The cleanup must succeed.
543
+ store.upsert(makeSession({ state: "zombie" }));
544
+
545
+ const stopOutcome = store.tryTransitionState("test-agent", "completed");
546
+ expect(stopOutcome.ok).toBe(true);
547
+ expect(store.getByName("test-agent")?.state).toBe("completed");
548
+ });
549
+
550
+ test("concurrent CAS: two writers try the same target, only one observes ok", () => {
551
+ // Simulates the SQL CAS exclusivity: two writers race against the same
552
+ // row with the same target. The DB serializes the writes; only the one
553
+ // that finds the row in an allowed-from state when the CAS executes
554
+ // reports ok=true. We can't truly run them in parallel from a single
555
+ // thread, but we can prove the invariant: after BOTH calls, the row
556
+ // is in the target state and exactly one call returned ok=true.
557
+ store.upsert(makeSession({ state: "working" }));
558
+
559
+ const a = store.tryTransitionState("test-agent", "completed");
560
+ const b = store.tryTransitionState("test-agent", "completed");
561
+
562
+ // First call lands (working → completed). Second is idempotent
563
+ // (completed → completed is in the matrix), so both report ok.
564
+ expect(a.ok).toBe(true);
565
+ expect(b.ok).toBe(true);
566
+ expect(store.getByName("test-agent")?.state).toBe("completed");
567
+ });
568
+
569
+ test("concurrent CAS: conflicting targets — second writer rejected", () => {
570
+ // First writer lands working → completed; second writer attempts
571
+ // working → zombie but the row is now completed → REJECTED.
572
+ store.upsert(makeSession({ state: "working" }));
573
+
574
+ const stop = store.tryTransitionState("test-agent", "completed");
575
+ expect(stop.ok).toBe(true);
576
+
577
+ const watchdog = store.tryTransitionState("test-agent", "zombie");
578
+ expect(watchdog.ok).toBe(false);
579
+ if (!watchdog.ok && watchdog.reason === "illegal_transition") {
580
+ expect(watchdog.prev).toBe("completed");
581
+ }
582
+ });
583
+ });
584
+
585
+ // === in_turn / between_turns spawn-per-turn substates (overstory-3087) ===
586
+ //
587
+ // The spawn-per-turn engine splits the legacy `working` state into two:
588
+ // `in_turn` (claude is mid-execution, parser events streaming) and
589
+ // `between_turns` (claude exited cleanly, agent waiting for the next mail
590
+ // batch). The matrix must allow the cycle in both directions and forward
591
+ // progression to terminal/error states from either substate. The CHECK
592
+ // constraint and `getActive` query must accept the new values so the
593
+ // watchdog and dashboards see these workers as alive.
594
+
595
+ describe("in_turn / between_turns substates", () => {
596
+ test("upsert accepts in_turn via CHECK constraint", () => {
597
+ store.upsert(makeSession({ state: "in_turn" }));
598
+ expect(store.getByName("test-agent")?.state).toBe("in_turn");
599
+ });
600
+
601
+ test("upsert accepts between_turns via CHECK constraint", () => {
602
+ store.upsert(makeSession({ state: "between_turns" }));
603
+ expect(store.getByName("test-agent")?.state).toBe("between_turns");
604
+ });
605
+
606
+ test("getActive includes in_turn and between_turns alongside working/booting/stalled", () => {
607
+ store.upsert(makeSession({ agentName: "a-it", id: "s-it", state: "in_turn" }));
608
+ store.upsert(makeSession({ agentName: "a-bt", id: "s-bt", state: "between_turns" }));
609
+ store.upsert(makeSession({ agentName: "a-w", id: "s-w", state: "working" }));
610
+ store.upsert(makeSession({ agentName: "a-c", id: "s-c", state: "completed" }));
611
+ store.upsert(makeSession({ agentName: "a-z", id: "s-z", state: "zombie" }));
612
+
613
+ const activeNames = store
614
+ .getActive()
615
+ .map((s) => s.agentName)
616
+ .sort();
617
+ expect(activeNames).toEqual(["a-bt", "a-it", "a-w"]);
618
+ });
619
+
620
+ test("booting → in_turn lands (turn-runner first-event transition)", () => {
621
+ store.upsert(makeSession({ state: "booting" }));
622
+ const outcome = store.tryTransitionState("test-agent", "in_turn");
623
+ expect(outcome.ok).toBe(true);
624
+ expect(store.getByName("test-agent")?.state).toBe("in_turn");
625
+ });
626
+
627
+ test("in_turn → between_turns lands (turn-runner end-of-turn settle)", () => {
628
+ store.upsert(makeSession({ state: "in_turn" }));
629
+ const outcome = store.tryTransitionState("test-agent", "between_turns");
630
+ expect(outcome.ok).toBe(true);
631
+ expect(store.getByName("test-agent")?.state).toBe("between_turns");
632
+ });
633
+
634
+ test("between_turns → in_turn lands (next mail batch starts a turn)", () => {
635
+ store.upsert(makeSession({ state: "between_turns" }));
636
+ const outcome = store.tryTransitionState("test-agent", "in_turn");
637
+ expect(outcome.ok).toBe(true);
638
+ expect(store.getByName("test-agent")?.state).toBe("in_turn");
639
+ });
640
+
641
+ test("legacy working → in_turn is rejected (spawn-per-turn keeps separate path)", () => {
642
+ // A row in the legacy `working` state predates the spawn-per-turn
643
+ // substate split. The matrix intentionally does not list `working` as
644
+ // a predecessor of `in_turn` — turn-runner.ts handles legacy `working`
645
+ // rows by writing in_turn directly via updateState() / unconditional
646
+ // override on the first parser event of a fresh batch. A
647
+ // CAS-guarded transition is rejected to keep the two paths disjoint.
648
+ store.upsert(makeSession({ state: "working" }));
649
+ const outcome = store.tryTransitionState("test-agent", "in_turn");
650
+ expect(outcome.ok).toBe(false);
651
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
652
+ expect(outcome.prev).toBe("working");
653
+ }
654
+ expect(store.getByName("test-agent")?.state).toBe("working");
655
+ });
656
+
657
+ test("legacy working → between_turns is rejected", () => {
658
+ store.upsert(makeSession({ state: "working" }));
659
+ const outcome = store.tryTransitionState("test-agent", "between_turns");
660
+ expect(outcome.ok).toBe(false);
661
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
662
+ expect(outcome.prev).toBe("working");
663
+ }
664
+ });
665
+
666
+ test("booting → between_turns is rejected (must pass through in_turn)", () => {
667
+ // Spec: between_turns predecessors are in_turn / between_turns /
668
+ // stalled. The agent only reaches between_turns after a turn produced
669
+ // events — which means the turn-runner must have transitioned to
670
+ // in_turn first.
671
+ store.upsert(makeSession({ state: "booting" }));
672
+ const outcome = store.tryTransitionState("test-agent", "between_turns");
673
+ expect(outcome.ok).toBe(false);
674
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
675
+ expect(outcome.prev).toBe("booting");
676
+ }
677
+ });
678
+
679
+ test("in_turn → completed lands (clean exit + terminal mail)", () => {
680
+ store.upsert(makeSession({ state: "in_turn" }));
681
+ const outcome = store.tryTransitionState("test-agent", "completed");
682
+ expect(outcome.ok).toBe(true);
683
+ expect(store.getByName("test-agent")?.state).toBe("completed");
684
+ });
685
+
686
+ test("between_turns → completed lands (operator stops an idle worker)", () => {
687
+ store.upsert(makeSession({ state: "between_turns" }));
688
+ const outcome = store.tryTransitionState("test-agent", "completed");
689
+ expect(outcome.ok).toBe(true);
690
+ expect(store.getByName("test-agent")?.state).toBe("completed");
691
+ });
692
+
693
+ test("in_turn → zombie lands (parser stall / abort)", () => {
694
+ store.upsert(makeSession({ state: "in_turn" }));
695
+ const outcome = store.tryTransitionState("test-agent", "zombie");
696
+ expect(outcome.ok).toBe(true);
697
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
698
+ });
699
+
700
+ test("between_turns → zombie lands (watchdog terminate after long idle)", () => {
701
+ store.upsert(makeSession({ state: "between_turns" }));
702
+ const outcome = store.tryTransitionState("test-agent", "zombie");
703
+ expect(outcome.ok).toBe(true);
704
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
705
+ });
706
+
707
+ test("in_turn → stalled lands (watchdog escalate)", () => {
708
+ store.upsert(makeSession({ state: "in_turn" }));
709
+ const outcome = store.tryTransitionState("test-agent", "stalled");
710
+ expect(outcome.ok).toBe(true);
711
+ expect(store.getByName("test-agent")?.state).toBe("stalled");
712
+ });
713
+
714
+ test("idempotent in_turn → in_turn is allowed (re-entering on same batch)", () => {
715
+ store.upsert(makeSession({ state: "in_turn" }));
716
+ const outcome = store.tryTransitionState("test-agent", "in_turn");
717
+ expect(outcome.ok).toBe(true);
718
+ });
719
+
720
+ test("idempotent between_turns → between_turns is allowed", () => {
721
+ store.upsert(makeSession({ state: "between_turns" }));
722
+ const outcome = store.tryTransitionState("test-agent", "between_turns");
723
+ expect(outcome.ok).toBe(true);
724
+ });
725
+
726
+ test("completed → in_turn is rejected (sticky completed)", () => {
727
+ store.upsert(makeSession({ state: "completed" }));
728
+ const outcome = store.tryTransitionState("test-agent", "in_turn");
729
+ expect(outcome.ok).toBe(false);
730
+ expect(store.getByName("test-agent")?.state).toBe("completed");
731
+ });
732
+
733
+ test("zombie → in_turn is rejected (turn-runner cannot revive zombie)", () => {
734
+ store.upsert(makeSession({ state: "zombie" }));
735
+ const outcome = store.tryTransitionState("test-agent", "in_turn");
736
+ expect(outcome.ok).toBe(false);
737
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
738
+ });
739
+
740
+ test("zombie → between_turns is rejected", () => {
741
+ store.upsert(makeSession({ state: "zombie" }));
742
+ const outcome = store.tryTransitionState("test-agent", "between_turns");
743
+ expect(outcome.ok).toBe(false);
744
+ expect(store.getByName("test-agent")?.state).toBe("zombie");
745
+ });
746
+
747
+ test("nothing transitions into booting from in_turn or between_turns", () => {
748
+ store.upsert(makeSession({ state: "in_turn" }));
749
+ const outcome = store.tryTransitionState("test-agent", "booting");
750
+ expect(outcome.ok).toBe(false);
751
+ if (!outcome.ok && outcome.reason === "illegal_transition") {
752
+ expect(outcome.prev).toBe("in_turn");
753
+ }
754
+ });
755
+ });
756
+
757
+ // === migration: pre-3087 CHECK constraint relaxation ===
758
+ //
759
+ // SQLite cannot DROP a CHECK constraint in place, so the old inline CHECK on
760
+ // the `state` column must be removed by rebuilding the table. The migration
761
+ // must preserve every existing row verbatim and let inserts of the new
762
+ // values (and any future state extensions) land without a schema bump.
763
+
764
+ describe("migration: drop legacy state CHECK constraint", () => {
765
+ test("rebuilds the table when the recorded CHECK predates 3087", async () => {
766
+ store.close();
767
+
768
+ const { Database: Db } = await import("bun:sqlite");
769
+ const legacyDb = new Db(dbPath);
770
+ legacyDb.exec("DROP TABLE IF EXISTS sessions");
771
+ // Recreate using the pre-3087 CHECK so the migration has something
772
+ // to detect and rebuild.
773
+ legacyDb.exec(`
774
+ CREATE TABLE sessions (
775
+ id TEXT PRIMARY KEY,
776
+ agent_name TEXT NOT NULL UNIQUE,
777
+ capability TEXT NOT NULL,
778
+ worktree_path TEXT NOT NULL,
779
+ branch_name TEXT NOT NULL,
780
+ task_id TEXT NOT NULL,
781
+ tmux_session TEXT NOT NULL,
782
+ state TEXT NOT NULL DEFAULT 'booting'
783
+ CHECK(state IN ('booting','working','completed','stalled','zombie')),
784
+ pid INTEGER,
785
+ parent_agent TEXT,
786
+ depth INTEGER NOT NULL DEFAULT 0,
787
+ run_id TEXT,
788
+ started_at TEXT NOT NULL,
789
+ last_activity TEXT NOT NULL,
790
+ escalation_level INTEGER NOT NULL DEFAULT 0,
791
+ stalled_since TEXT,
792
+ transcript_path TEXT,
793
+ prompt_version TEXT,
794
+ claude_session_id TEXT
795
+ )
796
+ `);
797
+ legacyDb.exec(`
798
+ INSERT INTO sessions
799
+ (id, agent_name, capability, worktree_path, branch_name, task_id,
800
+ tmux_session, state, started_at, last_activity)
801
+ VALUES
802
+ ('legacy-1','legacy-agent','builder','/tmp/wt','branch','task',
803
+ '','working','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')
804
+ `);
805
+ legacyDb.close();
806
+
807
+ // Opening a new SessionStore must run the migration and accept new states.
808
+ const migrated = createSessionStore(dbPath);
809
+ try {
810
+ expect(migrated.getByName("legacy-agent")?.state).toBe("working");
811
+ migrated.upsert(makeSession({ agentName: "fresh-it", id: "s-it", state: "in_turn" }));
812
+ migrated.upsert(makeSession({ agentName: "fresh-bt", id: "s-bt", state: "between_turns" }));
813
+ expect(migrated.getByName("fresh-it")?.state).toBe("in_turn");
814
+ expect(migrated.getByName("fresh-bt")?.state).toBe("between_turns");
815
+ } finally {
816
+ migrated.close();
817
+ }
818
+
819
+ store = createSessionStore(join(tempDir, "unused.db"));
820
+ });
821
+
822
+ test("is a no-op when the CHECK has already been dropped (idempotent)", () => {
823
+ // `store` was created by beforeEach against a fresh DB whose CREATE
824
+ // TABLE no longer carries a CHECK on `state`. Reopen on the same path
825
+ // and verify it does not throw or rebuild gratuitously.
826
+ store.upsert(makeSession({ state: "in_turn" }));
827
+
828
+ const reopened = createSessionStore(dbPath);
829
+ try {
830
+ expect(reopened.getByName("test-agent")?.state).toBe("in_turn");
831
+ } finally {
832
+ reopened.close();
833
+ }
834
+ });
835
+ });
836
+
837
+ // === tmux_session clearing on terminal transitions (overstory-14c0) ===
838
+ //
839
+ // The tmux session is torn down by ov stop / watchdog / coordinator cleanup
840
+ // before the state lands at completed/zombie. The stored tmux_session string
841
+ // would otherwise stay forever, surfacing dead session names in the agents
842
+ // view of `ov status`. Both updateState and tryTransitionState must clear it.
843
+
844
+ describe("tmux_session clearing on terminal transitions", () => {
845
+ const tmux = "overstory-test-agent";
846
+
847
+ describe("updateState", () => {
848
+ test("clears tmux_session when transitioning to completed", () => {
849
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
850
+ store.updateState("test-agent", "completed");
851
+ const result = store.getByName("test-agent");
852
+ expect(result?.state).toBe("completed");
853
+ expect(result?.tmuxSession).toBe("");
854
+ });
855
+
856
+ test("clears tmux_session when transitioning to zombie", () => {
857
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
858
+ store.updateState("test-agent", "zombie");
859
+ const result = store.getByName("test-agent");
860
+ expect(result?.state).toBe("zombie");
861
+ expect(result?.tmuxSession).toBe("");
862
+ });
863
+
864
+ test("preserves tmux_session when transitioning to a non-terminal state", () => {
865
+ store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
866
+ store.updateState("test-agent", "working");
867
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
868
+
869
+ store.updateState("test-agent", "stalled");
870
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
871
+ });
872
+
873
+ test("idempotent terminal write keeps tmux_session cleared", () => {
874
+ store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
875
+ store.updateState("test-agent", "completed");
876
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
877
+ });
878
+ });
879
+
880
+ describe("tryTransitionState", () => {
881
+ test("clears tmux_session on working → completed", () => {
882
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
883
+ const outcome = store.tryTransitionState("test-agent", "completed");
884
+ expect(outcome.ok).toBe(true);
885
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
886
+ });
887
+
888
+ test("clears tmux_session on working → zombie", () => {
889
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
890
+ const outcome = store.tryTransitionState("test-agent", "zombie");
891
+ expect(outcome.ok).toBe(true);
892
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
893
+ });
894
+
895
+ test("clears tmux_session on zombie → completed (ov stop cleanup of zombie)", () => {
896
+ // Zombie may still hold a tmux_session if the watchdog landed before
897
+ // any cleanup pass; the subsequent ov stop must wipe it.
898
+ store.upsert(makeSession({ state: "zombie", tmuxSession: tmux }));
899
+ const outcome = store.tryTransitionState("test-agent", "completed");
900
+ expect(outcome.ok).toBe(true);
901
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
902
+ });
903
+
904
+ test("clears tmux_session on stalled → zombie (watchdog terminate)", () => {
905
+ store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
906
+ const outcome = store.tryTransitionState("test-agent", "zombie");
907
+ expect(outcome.ok).toBe(true);
908
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
909
+ });
910
+
911
+ test("preserves tmux_session on non-terminal targets (booting → working)", () => {
912
+ store.upsert(makeSession({ state: "booting", tmuxSession: tmux }));
913
+ const outcome = store.tryTransitionState("test-agent", "working");
914
+ expect(outcome.ok).toBe(true);
915
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
916
+ });
917
+
918
+ test("preserves tmux_session on non-terminal targets (stalled → working)", () => {
919
+ store.upsert(makeSession({ state: "stalled", tmuxSession: tmux }));
920
+ const outcome = store.tryTransitionState("test-agent", "working");
921
+ expect(outcome.ok).toBe(true);
922
+ expect(store.getByName("test-agent")?.tmuxSession).toBe(tmux);
923
+ });
924
+
925
+ test("does not clear tmux_session when CAS rejects an illegal terminal transition", () => {
926
+ // completed → zombie is rejected. The row must remain untouched —
927
+ // in particular, tmux_session must keep whatever the row already
928
+ // held (an empty string here, since the prior completed write
929
+ // cleared it).
930
+ store.upsert(makeSession({ state: "completed", tmuxSession: "" }));
931
+ const outcome = store.tryTransitionState("test-agent", "zombie");
932
+ expect(outcome.ok).toBe(false);
933
+ expect(store.getByName("test-agent")?.tmuxSession).toBe("");
934
+ });
935
+
936
+ test("does not clear tmux_session when CAS rejects against a live row", () => {
937
+ // nothing transitions into booting; a working row keeps both state
938
+ // and tmux_session.
939
+ store.upsert(makeSession({ state: "working", tmuxSession: tmux }));
940
+ const outcome = store.tryTransitionState("test-agent", "booting");
941
+ expect(outcome.ok).toBe(false);
942
+ const result = store.getByName("test-agent");
943
+ expect(result?.state).toBe("working");
944
+ expect(result?.tmuxSession).toBe(tmux);
945
+ });
342
946
  });
343
947
  });
344
948
 
@@ -677,11 +1281,29 @@ describe("RunStore", () => {
677
1281
  expect(result).toBeNull();
678
1282
  });
679
1283
 
680
- test("creates a run with explicit agentCount", () => {
1284
+ test("agent count is derived from sessions in the same run", () => {
681
1285
  runStore.createRun(makeRun({ agentCount: 5 }));
1286
+ // `agentCount` no longer reflects the column; it's a live count of
1287
+ // sessions whose `run_id` matches. With no sessions, count is 0.
1288
+ expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(0);
682
1289
 
683
- const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
684
- expect(result?.agentCount).toBe(5);
1290
+ store.upsert(
1291
+ makeSession({
1292
+ id: "s-1",
1293
+ agentName: "a-1",
1294
+ runId: "run-2026-02-13T10:00:00.000Z",
1295
+ }),
1296
+ );
1297
+ expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(1);
1298
+
1299
+ store.upsert(
1300
+ makeSession({
1301
+ id: "s-2",
1302
+ agentName: "a-2",
1303
+ runId: "run-2026-02-13T10:00:00.000Z",
1304
+ }),
1305
+ );
1306
+ expect(runStore.getRun("run-2026-02-13T10:00:00.000Z")?.agentCount).toBe(2);
685
1307
  });
686
1308
 
687
1309
  test("creates a run with null coordinatorSessionId", () => {
@@ -840,33 +1462,24 @@ describe("RunStore", () => {
840
1462
  });
841
1463
 
842
1464
  // === incrementAgentCount ===
1465
+ //
1466
+ // Retained as a no-op for API compatibility. `agentCount` is now derived
1467
+ // from the sessions table at read time (overstory-8e69), so calls have no
1468
+ // effect on what `getRun` returns.
843
1469
 
844
1470
  describe("incrementAgentCount", () => {
845
- test("increments agent count by 1", () => {
1471
+ test("is a no-op — agent count comes from sessions, not the column", () => {
846
1472
  runStore.createRun(makeRun());
847
1473
 
848
1474
  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
1475
  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
1476
 
857
- test("is a no-op for nonexistent run (does not throw)", () => {
858
- // Should not throw
859
- runStore.incrementAgentCount("nonexistent-run");
1477
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
1478
+ expect(result?.agentCount).toBe(0);
860
1479
  });
861
1480
 
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");
1481
+ test("is safe to call for a nonexistent run (does not throw)", () => {
1482
+ runStore.incrementAgentCount("nonexistent-run");
870
1483
  });
871
1484
  });
872
1485
 
@@ -904,9 +1517,15 @@ describe("RunStore", () => {
904
1517
 
905
1518
  test("preserves agent count when completing", () => {
906
1519
  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");
1520
+ for (let i = 0; i < 3; i++) {
1521
+ store.upsert(
1522
+ makeSession({
1523
+ id: `s-${i}`,
1524
+ agentName: `a-${i}`,
1525
+ runId: "run-2026-02-13T10:00:00.000Z",
1526
+ }),
1527
+ );
1528
+ }
910
1529
 
911
1530
  runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
912
1531
 
@@ -1106,6 +1725,15 @@ describe("RunStore", () => {
1106
1725
  };
1107
1726
 
1108
1727
  runStore.createRun(run);
1728
+ for (let i = 0; i < 7; i++) {
1729
+ store.upsert(
1730
+ makeSession({
1731
+ id: `s-rt-${i}`,
1732
+ agentName: `a-rt-${i}`,
1733
+ runId: "run-roundtrip-test",
1734
+ }),
1735
+ );
1736
+ }
1109
1737
  const result = runStore.getRun("run-roundtrip-test");
1110
1738
 
1111
1739
  expect(result).not.toBeNull();