@os-eco/overstory-cli 0.10.3 → 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 (44) hide show
  1. package/README.md +4 -2
  2. package/agents/builder.md +10 -1
  3. package/agents/lead.md +106 -5
  4. package/package.json +1 -1
  5. package/src/agents/headless-mail-injector.ts +8 -0
  6. package/src/agents/mail-poll-detect.test.ts +153 -0
  7. package/src/agents/mail-poll-detect.ts +73 -0
  8. package/src/agents/overlay.test.ts +56 -0
  9. package/src/agents/overlay.ts +33 -0
  10. package/src/agents/scope-detect.test.ts +190 -0
  11. package/src/agents/scope-detect.ts +146 -0
  12. package/src/agents/turn-runner.test.ts +862 -0
  13. package/src/agents/turn-runner.ts +225 -8
  14. package/src/commands/agents.ts +9 -0
  15. package/src/commands/coordinator.test.ts +127 -0
  16. package/src/commands/coordinator.ts +71 -4
  17. package/src/commands/dashboard.ts +1 -1
  18. package/src/commands/log.test.ts +131 -0
  19. package/src/commands/log.ts +37 -2
  20. package/src/commands/merge.test.ts +118 -0
  21. package/src/commands/merge.ts +51 -8
  22. package/src/commands/sling.test.ts +104 -0
  23. package/src/commands/sling.ts +95 -8
  24. package/src/commands/stop.test.ts +81 -0
  25. package/src/index.ts +5 -1
  26. package/src/insights/quality-gates.test.ts +141 -0
  27. package/src/insights/quality-gates.ts +156 -0
  28. package/src/logging/theme.ts +4 -0
  29. package/src/merge/predict.test.ts +387 -0
  30. package/src/merge/predict.ts +249 -0
  31. package/src/merge/resolver.ts +1 -1
  32. package/src/mulch/client.ts +3 -3
  33. package/src/sessions/store.test.ts +267 -5
  34. package/src/sessions/store.ts +105 -7
  35. package/src/types.ts +51 -1
  36. package/src/watchdog/daemon.test.ts +124 -2
  37. package/src/watchdog/daemon.ts +27 -12
  38. package/src/watchdog/health.test.ts +133 -8
  39. package/src/watchdog/health.ts +37 -5
  40. package/src/worktree/manager.test.ts +218 -1
  41. package/src/worktree/manager.ts +55 -0
  42. package/src/worktree/tmux.test.ts +25 -0
  43. package/src/worktree/tmux.ts +17 -0
  44. package/templates/overlay.md.tmpl +2 -0
@@ -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", () => {
@@ -376,9 +382,13 @@ describe("updateState", () => {
376
382
  store.updateState("nonexistent", "completed");
377
383
  });
378
384
 
379
- 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.
380
389
  store.upsert(makeSession());
381
- 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);
382
392
  });
383
393
  });
384
394
 
@@ -572,6 +582,258 @@ describe("tryTransitionState", () => {
572
582
  });
573
583
  });
574
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
+
575
837
  // === tmux_session clearing on terminal transitions (overstory-14c0) ===
576
838
  //
577
839
  // The tmux session is torn down by ov stop / watchdog / coordinator cleanup
@@ -29,15 +29,25 @@ import type {
29
29
  * wrote zombie is rejected — last writer no longer wins.
30
30
  * - Idempotent self-transitions (e.g. `working → working`) are allowed.
31
31
  * - `booting` is set only by the initial `upsert` and never re-entered.
32
+ * - `in_turn` and `between_turns` cycle while a spawn-per-turn worker is
33
+ * alive (overstory-3087): turn-runner advances `between_turns → in_turn`
34
+ * when the next batch produces its first parser event and settles back
35
+ * `in_turn → between_turns` when the turn ends without a terminal mail.
36
+ * Both can advance forward to `stalled`/`zombie`/`completed`. The two
37
+ * paths are kept separate from the tmux/long-lived `working` rank — a
38
+ * spawn-per-turn worker should not flow through `working` during normal
39
+ * operation — so neither lists `working` as a predecessor.
32
40
  *
33
41
  * See overstory-a993 for the race symptoms this guard prevents.
34
42
  */
35
43
  const TRANSITION_ALLOWED_FROM: Record<AgentState, readonly AgentState[]> = {
36
44
  booting: [],
37
45
  working: ["booting", "working", "stalled"],
38
- stalled: ["booting", "working", "stalled"],
39
- completed: ["booting", "working", "stalled", "zombie", "completed"],
40
- zombie: ["booting", "working", "stalled", "zombie"],
46
+ in_turn: ["booting", "in_turn", "between_turns", "stalled"],
47
+ between_turns: ["in_turn", "between_turns", "stalled"],
48
+ stalled: ["booting", "working", "in_turn", "between_turns", "stalled"],
49
+ completed: ["booting", "working", "in_turn", "between_turns", "stalled", "zombie", "completed"],
50
+ zombie: ["booting", "working", "in_turn", "between_turns", "stalled", "zombie"],
41
51
  };
42
52
 
43
53
  /**
@@ -57,7 +67,14 @@ export interface SessionStore {
57
67
  upsert(session: AgentSession): void;
58
68
  /** Get a session by agent name, or null if not found. */
59
69
  getByName(agentName: string): AgentSession | null;
60
- /** Get all active sessions (state IN ('booting', 'working', 'stalled')). */
70
+ /**
71
+ * Get all active sessions (state IN ('booting', 'working', 'in_turn',
72
+ * 'between_turns', 'stalled')).
73
+ *
74
+ * `in_turn` and `between_turns` are spawn-per-turn equivalents of `working`
75
+ * and must be returned by `getActive` so the watchdog and dashboards see
76
+ * spawn-per-turn workers as alive (overstory-3087).
77
+ */
61
78
  getActive(): AgentSession[];
62
79
  /** Get all sessions regardless of state. */
63
80
  getAll(): AgentSession[];
@@ -142,8 +159,7 @@ CREATE TABLE IF NOT EXISTS sessions (
142
159
  branch_name TEXT NOT NULL,
143
160
  task_id TEXT NOT NULL,
144
161
  tmux_session TEXT NOT NULL,
145
- state TEXT NOT NULL DEFAULT 'booting'
146
- CHECK(state IN ('booting','working','completed','stalled','zombie')),
162
+ state TEXT NOT NULL DEFAULT 'booting',
147
163
  pid INTEGER,
148
164
  parent_agent TEXT,
149
165
  depth INTEGER NOT NULL DEFAULT 0,
@@ -251,6 +267,83 @@ function migrateAddClaudeSessionId(db: Database): void {
251
267
  }
252
268
  }
253
269
 
270
+ /**
271
+ * Drop the inline CHECK(state IN (...)) constraint from the sessions table
272
+ * (overstory-3087).
273
+ *
274
+ * The CHECK was defensive — the TypeScript `AgentState` union enforces values
275
+ * at the writer boundary. With the spawn-per-turn substate split (`in_turn` /
276
+ * `between_turns`) and likely future state extensions, keeping the constraint
277
+ * in sync with the union via inline-CHECK rebuilds becomes a recurring tax.
278
+ * Drop it and rely on the type system.
279
+ *
280
+ * SQLite has no `ALTER TABLE DROP CONSTRAINT`, so we detect the old constraint
281
+ * via `sqlite_master.sql` (the recorded CREATE TABLE DDL), then rebuild the
282
+ * table inside a transaction: copy rows verbatim into a new constraint-free
283
+ * schema, drop the original, and rename. Indexes are dropped by the swap and
284
+ * re-created by the caller via CREATE_INDEXES, which is idempotent.
285
+ *
286
+ * Safe to call multiple times — short-circuits when the recorded DDL no
287
+ * longer contains a CHECK on `state`. Must run BEFORE indexes are created
288
+ * (the swap drops them) and BEFORE the column-add migrations that read
289
+ * `PRAGMA table_info` on the legacy table (the new table inherits any added
290
+ * columns via the rebuild, so the column-add migrations become idempotent).
291
+ */
292
+ function migrateRelaxStateCheck(db: Database): void {
293
+ const row = db
294
+ .prepare<{ sql: string | null }, []>(
295
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
296
+ )
297
+ .get();
298
+ if (!row || row.sql === null) return;
299
+ // Detect the inline CHECK on the `state` column. Match conservatively on
300
+ // the literal "CHECK(state IN" — any whitespace variant SQLite stores
301
+ // will still contain this substring.
302
+ if (!row.sql.includes("CHECK(state IN")) return;
303
+
304
+ // Discover the columns that exist on the LIVE table so the rebuild copies
305
+ // every column the column-add migrations have layered on. Hard-coding the
306
+ // column list would silently drop newer columns when this migration runs
307
+ // against a DB that earlier migrations have already extended.
308
+ const colInfo = db.prepare("PRAGMA table_info(sessions)").all() as Array<{
309
+ name: string;
310
+ type: string;
311
+ notnull: number;
312
+ dflt_value: string | null;
313
+ pk: number;
314
+ }>;
315
+
316
+ // Render each column for the new CREATE TABLE. PRIMARY KEY and UNIQUE are
317
+ // preserved on `id` and `agent_name` respectively to match the original
318
+ // schema; everything else is straight type + nullability + default.
319
+ const colDefs = colInfo
320
+ .map((c) => {
321
+ const parts: string[] = [c.name, c.type || "TEXT"];
322
+ if (c.pk === 1) parts.push("PRIMARY KEY");
323
+ if (c.notnull === 1) parts.push("NOT NULL");
324
+ if (c.dflt_value !== null) parts.push(`DEFAULT ${c.dflt_value}`);
325
+ if (c.name === "agent_name") parts.push("UNIQUE");
326
+ return `\t\t\t\t${parts.join(" ")}`;
327
+ })
328
+ .join(",\n");
329
+ const colNames = colInfo.map((c) => c.name).join(", ");
330
+
331
+ db.exec("BEGIN");
332
+ try {
333
+ db.exec(`CREATE TABLE sessions__new_3087 (\n${colDefs}\n\t\t\t)`);
334
+ db.exec(`
335
+ INSERT INTO sessions__new_3087 (${colNames})
336
+ SELECT ${colNames} FROM sessions
337
+ `);
338
+ db.exec("DROP TABLE sessions");
339
+ db.exec("ALTER TABLE sessions__new_3087 RENAME TO sessions");
340
+ db.exec("COMMIT");
341
+ } catch (err) {
342
+ db.exec("ROLLBACK");
343
+ throw err;
344
+ }
345
+ }
346
+
254
347
  /**
255
348
  * Migrate an existing sessions table from bead_id to task_id column.
256
349
  * Safe to call multiple times — only renames if bead_id exists and task_id does not.
@@ -282,6 +375,10 @@ export function createSessionStore(dbPath: string): SessionStore {
282
375
  db.exec(CREATE_RUNS_TABLE);
283
376
 
284
377
  // Migrate existing tables BEFORE creating indexes that reference new columns.
378
+ // `migrateRelaxStateCheck` runs FIRST so the column-add migrations that
379
+ // follow operate on the rebuilt table — they read PRAGMA table_info and
380
+ // ADD COLUMN, both of which work on the new constraint-free schema.
381
+ migrateRelaxStateCheck(db);
285
382
  migrateBeadIdToTaskId(db);
286
383
  migrateAddTranscriptPath(db);
287
384
  migrateAddPromptVersion(db);
@@ -353,7 +450,8 @@ export function createSessionStore(dbPath: string): SessionStore {
353
450
  `);
354
451
 
355
452
  const getActiveStmt = db.prepare<SessionRow, Record<string, never>>(`
356
- SELECT * FROM sessions WHERE state IN ('booting', 'working', 'stalled')
453
+ SELECT * FROM sessions
454
+ WHERE state IN ('booting', 'working', 'in_turn', 'between_turns', 'stalled')
357
455
  ORDER BY started_at ASC
358
456
  `);
359
457
 
package/src/types.ts CHANGED
@@ -187,7 +187,33 @@ export type Capability = (typeof SUPPORTED_CAPABILITIES)[number];
187
187
 
188
188
  // === Agent Session ===
189
189
 
190
- export type AgentState = "booting" | "working" | "completed" | "stalled" | "zombie";
190
+ /**
191
+ * Agent lifecycle states.
192
+ *
193
+ * `in_turn` and `between_turns` are spawn-per-turn-specific substates that
194
+ * split the legacy `working` state so the UI can distinguish a worker actively
195
+ * executing a turn from one idling between mail batches (overstory-3087):
196
+ *
197
+ * - `in_turn`: the turn-runner has observed at least one parser event from
198
+ * a live claude subprocess. The agent is mid-execution.
199
+ * - `between_turns`: the turn-runner finished a turn without a terminal
200
+ * mail; the agent is alive (process gone, session pinned) and waiting
201
+ * for the next mail batch to spawn a fresh turn.
202
+ *
203
+ * `working` remains the active state for tmux/long-lived headless agents
204
+ * (coordinator, orchestrator, monitor, sapling) which have no per-turn
205
+ * boundary. Spawn-per-turn workers (builder/scout/reviewer/lead/merger
206
+ * under the headless default) transition through in_turn ↔ between_turns
207
+ * instead.
208
+ */
209
+ export type AgentState =
210
+ | "booting"
211
+ | "working"
212
+ | "in_turn"
213
+ | "between_turns"
214
+ | "completed"
215
+ | "stalled"
216
+ | "zombie";
191
217
 
192
218
  /**
193
219
  * Result of a guarded state transition attempt (`SessionStore.tryTransitionState`).
@@ -446,6 +472,13 @@ export interface OverlayConfig {
446
472
  qualityGates?: QualityGate[];
447
473
  /** Relative path to the instruction file within the worktree (runtime-specific). Defaults to .claude/CLAUDE.md. */
448
474
  instructionPath?: string;
475
+ /**
476
+ * Names of sibling agents dispatched in parallel that may share file scope
477
+ * with this agent. When set, the overlay renders a "Parallel Siblings"
478
+ * section with rebase-before-merge_ready guidance (overstory-f76a). Empty
479
+ * or unset → no overlay section.
480
+ */
481
+ siblings?: string[];
449
482
  }
450
483
 
451
484
  // === Merge Queue ===
@@ -491,6 +524,23 @@ export interface ConflictHistory {
491
524
  predictedConflictFiles: string[];
492
525
  }
493
526
 
527
+ /**
528
+ * Side-effect-free prediction of how `ov merge` would resolve a branch.
529
+ * Produced by `predictConflicts` (src/merge/predict.ts) without touching HEAD,
530
+ * the working tree, or the merge lock — surfaced via `ov merge --dry-run` so a
531
+ * lead/operator/greenhouse can branch on `wouldRequireAgent`.
532
+ */
533
+ export interface ConflictPrediction {
534
+ /** The tier `ov merge` would land in if invoked now. */
535
+ predictedTier: ResolutionTier;
536
+ /** Files that would conflict — empty for clean-merge. */
537
+ conflictFiles: string[];
538
+ /** True iff predictedTier is "ai-resolve" or "reimagine" (Tier 3+). */
539
+ wouldRequireAgent: boolean;
540
+ /** Short, operator-readable explanation for the predicted tier. */
541
+ reason: string;
542
+ }
543
+
494
544
  // === Watchdog ===
495
545
 
496
546
  export interface HealthCheck {
@@ -531,6 +531,123 @@ describe("daemon tick", () => {
531
531
  expect(reloaded[0]?.stalledSince).toBeNull();
532
532
  });
533
533
 
534
+ // Regression tests for overstory-74ce: killAgent() must never call
535
+ // tmux.killSession("") for headless agents — an empty `-t` argument is
536
+ // prefix-matched and would wildcard-kill the entire overstory tmux server.
537
+
538
+ test("spawn-per-turn agent at level 3 termination does NOT call tmux.killSession", async () => {
539
+ const nudgeIntervalMs = 60_000;
540
+ const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
541
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
542
+
543
+ // Spawn-per-turn worker between turns: tmuxSession === "" AND pid === null.
544
+ // Before the fix, killAgent fell through to tmux.killSession("") which
545
+ // prefix-matches every session in the overstory tmux server.
546
+ const session = makeSession({
547
+ agentName: "spawn-per-turn-doomed",
548
+ tmuxSession: "",
549
+ pid: null,
550
+ state: "stalled",
551
+ lastActivity: staleActivity,
552
+ escalationLevel: 2,
553
+ stalledSince,
554
+ });
555
+
556
+ writeSessionsToStore(tempRoot, [session]);
557
+
558
+ // No tmux sessions registered — emulates production where the spawn-per-turn
559
+ // agent has no named session.
560
+ const tmuxMock = tmuxWithLiveness({});
561
+
562
+ await runDaemonTick({
563
+ root: tempRoot,
564
+ ...THRESHOLDS,
565
+ nudgeIntervalMs,
566
+ tier1Enabled: false,
567
+ _tmux: tmuxMock,
568
+ _triage: triageAlways("extend"),
569
+ _nudge: nudgeTracker().nudge,
570
+ _eventStore: null,
571
+ _recordFailure: async () => {},
572
+ _getConnection: () => undefined,
573
+ _removeConnection: () => {},
574
+ _tailerRegistry: new Map(),
575
+ _findLatestStdoutLog: async () => null,
576
+ });
577
+
578
+ // Critical assertion: no wildcard kill attempt. tmuxMock.killed must be empty.
579
+ expect(tmuxMock.killed).toHaveLength(0);
580
+
581
+ // The session is still transitioned to zombie — termination semantics are preserved,
582
+ // just without the wildcard tmux kill.
583
+ const reloaded = readSessionsFromStore(tempRoot);
584
+ expect(reloaded[0]?.state).toBe("zombie");
585
+ expect(reloaded[0]?.escalationLevel).toBe(0);
586
+ expect(reloaded[0]?.stalledSince).toBeNull();
587
+ });
588
+
589
+ test("long-lived headless agent at level 3 termination kills pid tree, not tmux", async () => {
590
+ const nudgeIntervalMs = 60_000;
591
+ const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
592
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
593
+
594
+ // Long-lived headless capability (e.g. coordinator/orchestrator/monitor):
595
+ // tmuxSession === "" AND pid !== null. The PID tree should be killed; tmux
596
+ // must not be touched.
597
+ const session = makeSession({
598
+ agentName: "headless-long-lived-doomed",
599
+ tmuxSession: "",
600
+ pid: process.pid, // alive PID — health eval won't short-circuit to direct terminate
601
+ state: "stalled",
602
+ lastActivity: staleActivity,
603
+ escalationLevel: 2,
604
+ stalledSince,
605
+ });
606
+
607
+ writeSessionsToStore(tempRoot, [session]);
608
+
609
+ const killedPids: number[] = [];
610
+ const procMock = {
611
+ isAlive: (pid: number) => {
612
+ try {
613
+ process.kill(pid, 0);
614
+ return true;
615
+ } catch {
616
+ return false;
617
+ }
618
+ },
619
+ killTree: async (pid: number) => {
620
+ killedPids.push(pid);
621
+ },
622
+ };
623
+
624
+ const tmuxMock = tmuxWithLiveness({});
625
+
626
+ await runDaemonTick({
627
+ root: tempRoot,
628
+ ...THRESHOLDS,
629
+ nudgeIntervalMs,
630
+ tier1Enabled: false,
631
+ _tmux: tmuxMock,
632
+ _triage: triageAlways("extend"),
633
+ _nudge: nudgeTracker().nudge,
634
+ _process: procMock,
635
+ _eventStore: null,
636
+ _recordFailure: async () => {},
637
+ _getConnection: () => undefined,
638
+ _removeConnection: () => {},
639
+ _tailerRegistry: new Map(),
640
+ _findLatestStdoutLog: async () => null,
641
+ });
642
+
643
+ // PID tree was killed; tmux.killSession was never called.
644
+ expect(killedPids).toContain(process.pid);
645
+ expect(tmuxMock.killed).toHaveLength(0);
646
+
647
+ const reloaded = readSessionsFromStore(tempRoot);
648
+ expect(reloaded[0]?.state).toBe("zombie");
649
+ });
650
+
534
651
  test("triage retry sends nudge with recovery message", async () => {
535
652
  const staleActivity = new Date(Date.now() - 60_000).toISOString();
536
653
  const stalledSince = new Date(Date.now() - 130_000).toISOString();
@@ -2765,10 +2882,15 @@ describe("headless agent stale detection via events.db (Bug 2)", () => {
2765
2882
  });
2766
2883
 
2767
2884
  // lastActivity refreshed from events.db → spawn-per-turn evaluation
2768
- // path keeps the agent in working, NOT zombie.
2885
+ // path keeps the agent active (action=none), NOT zombie. The
2886
+ // healthy classification reports `between_turns` (overstory-3087)
2887
+ // for spawn-per-turn workers; the legacy `working` row stays at
2888
+ // `working` on disk because the matrix does not list `working` as
2889
+ // a predecessor of `between_turns` and the CAS rejects the write
2890
+ // (the substate cycle is reserved for the turn-runner).
2769
2891
  expect(checks).toHaveLength(1);
2770
2892
  expect(checks[0]?.action).toBe("none");
2771
- expect(checks[0]?.state).toBe("working");
2893
+ expect(checks[0]?.state).toBe("between_turns");
2772
2894
 
2773
2895
  const reloaded = readSessionsFromStore(tempRoot);
2774
2896
  expect(reloaded[0]?.state).toBe("working");