@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
@@ -7,14 +7,74 @@
7
7
  */
8
8
 
9
9
  import { Database } from "bun:sqlite";
10
- import type { AgentSession, AgentState, InsertRun, Run, RunStatus, RunStore } from "../types.ts";
10
+ import type {
11
+ AgentSession,
12
+ AgentState,
13
+ InsertRun,
14
+ Run,
15
+ RunStatus,
16
+ RunStore,
17
+ TransitionOutcome,
18
+ } from "../types.ts";
19
+
20
+ /**
21
+ * Allowed predecessor states for each target state, enforced by
22
+ * `tryTransitionState` via an atomic SQL compare-and-swap.
23
+ *
24
+ * Invariants:
25
+ * - `completed` is sticky: nothing transitions out of it. The watchdog cannot
26
+ * reclassify a properly-completed agent as zombie.
27
+ * - `zombie` is durable except `ov stop` may promote it to `completed` for
28
+ * cleanup. A turn-runner that "settles to working" after watchdog already
29
+ * wrote zombie is rejected — last writer no longer wins.
30
+ * - Idempotent self-transitions (e.g. `working → working`) are allowed.
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.
40
+ *
41
+ * See overstory-a993 for the race symptoms this guard prevents.
42
+ */
43
+ const TRANSITION_ALLOWED_FROM: Record<AgentState, readonly AgentState[]> = {
44
+ booting: [],
45
+ working: ["booting", "working", "stalled"],
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"],
51
+ };
52
+
53
+ /**
54
+ * States in which an agent's tmux session no longer exists. When a session
55
+ * lands in one of these, `tmux_session` is cleared to `''` so the agents-side
56
+ * view stops surfacing tmux session names that have been torn down.
57
+ *
58
+ * The live `tmuxSessions` array on `ov status` reflects what tmux actually
59
+ * reports; the stored `tmux_session` column is what the agents-side view reads.
60
+ * Without this clear, completed/zombie agents carry stale tmux strings forever
61
+ * (overstory-14c0).
62
+ */
63
+ const TERMINAL_STATES: readonly AgentState[] = ["completed", "zombie"];
11
64
 
12
65
  export interface SessionStore {
13
66
  /** Insert or update a session. Uses agent_name as the unique key. */
14
67
  upsert(session: AgentSession): void;
15
68
  /** Get a session by agent name, or null if not found. */
16
69
  getByName(agentName: string): AgentSession | null;
17
- /** 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
+ */
18
78
  getActive(): AgentSession[];
19
79
  /** Get all sessions regardless of state. */
20
80
  getAll(): AgentSession[];
@@ -22,14 +82,32 @@ export interface SessionStore {
22
82
  count(): number;
23
83
  /** Get sessions belonging to a specific run. */
24
84
  getByRun(runId: string): AgentSession[];
25
- /** Update only the state of a session. */
85
+ /**
86
+ * Update only the state of a session.
87
+ *
88
+ * Unconditional override — does not validate the prev → next transition.
89
+ * Reserved for forced cleanup paths (`ov clean`, `ov sling` startup failure,
90
+ * supervisor/coordinator/monitor self-management). For race-prone writers
91
+ * (turn-runner settle, `ov stop`, watchdog), use `tryTransitionState`.
92
+ */
26
93
  updateState(agentName: string, state: AgentState): void;
94
+ /**
95
+ * Atomically transition a session's state, validated against the matrix in
96
+ * `TRANSITION_ALLOWED_FROM`. Implemented as a single `UPDATE ... WHERE state
97
+ * IN (...)` so concurrent writers cannot both succeed against the same row.
98
+ *
99
+ * Returns a discriminated outcome describing whether the write landed and,
100
+ * on rejection, whether the row was missing or the transition was illegal.
101
+ */
102
+ tryTransitionState(agentName: string, newState: AgentState): TransitionOutcome;
27
103
  /** Update lastActivity to current ISO timestamp. */
28
104
  updateLastActivity(agentName: string): void;
29
105
  /** Update escalation level and stalled timestamp. */
30
106
  updateEscalation(agentName: string, level: number, stalledSince: string | null): void;
31
107
  /** Update the transcript path for a session. */
32
108
  updateTranscriptPath(agentName: string, path: string): void;
109
+ /** Update the runtime-provided session_id (e.g. Claude stream-json session_id). */
110
+ updateClaudeSessionId(agentName: string, sessionId: string): void;
33
111
  /** Remove a session by agent name. */
34
112
  remove(agentName: string): void;
35
113
  /** Purge sessions matching criteria. Returns count of deleted rows. */
@@ -58,6 +136,7 @@ interface SessionRow {
58
136
  stalled_since: string | null;
59
137
  transcript_path: string | null;
60
138
  prompt_version: string | null;
139
+ claude_session_id: string | null;
61
140
  }
62
141
 
63
142
  /** Row shape for runs table as stored in SQLite (snake_case columns). */
@@ -80,8 +159,7 @@ CREATE TABLE IF NOT EXISTS sessions (
80
159
  branch_name TEXT NOT NULL,
81
160
  task_id TEXT NOT NULL,
82
161
  tmux_session TEXT NOT NULL,
83
- state TEXT NOT NULL DEFAULT 'booting'
84
- CHECK(state IN ('booting','working','completed','stalled','zombie')),
162
+ state TEXT NOT NULL DEFAULT 'booting',
85
163
  pid INTEGER,
86
164
  parent_agent TEXT,
87
165
  depth INTEGER NOT NULL DEFAULT 0,
@@ -91,7 +169,8 @@ CREATE TABLE IF NOT EXISTS sessions (
91
169
  escalation_level INTEGER NOT NULL DEFAULT 0,
92
170
  stalled_since TEXT,
93
171
  transcript_path TEXT,
94
- prompt_version TEXT
172
+ prompt_version TEXT,
173
+ claude_session_id TEXT
95
174
  )`;
96
175
 
97
176
  const CREATE_INDEXES = `
@@ -135,6 +214,7 @@ function rowToSession(row: SessionRow): AgentSession {
135
214
  stalledSince: row.stalled_since,
136
215
  transcriptPath: row.transcript_path,
137
216
  ...(row.prompt_version !== null ? { promptVersion: row.prompt_version } : {}),
217
+ ...(row.claude_session_id !== null ? { claudeSessionId: row.claude_session_id } : {}),
138
218
  };
139
219
  }
140
220
 
@@ -175,6 +255,95 @@ function migrateAddPromptVersion(db: Database): void {
175
255
  }
176
256
  }
177
257
 
258
+ /**
259
+ * Migrate an existing sessions table to add the claude_session_id column.
260
+ * Safe to call multiple times — only adds the column if it does not exist.
261
+ */
262
+ function migrateAddClaudeSessionId(db: Database): void {
263
+ const rows = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
264
+ const existingColumns = new Set(rows.map((r) => r.name));
265
+ if (!existingColumns.has("claude_session_id")) {
266
+ db.exec("ALTER TABLE sessions ADD COLUMN claude_session_id TEXT");
267
+ }
268
+ }
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
+
178
347
  /**
179
348
  * Migrate an existing sessions table from bead_id to task_id column.
180
349
  * Safe to call multiple times — only renames if bead_id exists and task_id does not.
@@ -206,9 +375,14 @@ export function createSessionStore(dbPath: string): SessionStore {
206
375
  db.exec(CREATE_RUNS_TABLE);
207
376
 
208
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);
209
382
  migrateBeadIdToTaskId(db);
210
383
  migrateAddTranscriptPath(db);
211
384
  migrateAddPromptVersion(db);
385
+ migrateAddClaudeSessionId(db);
212
386
  migrateAddCoordinatorName(db);
213
387
 
214
388
  // Now safe to create indexes (all columns exist).
@@ -237,18 +411,19 @@ export function createSessionStore(dbPath: string): SessionStore {
237
411
  $stalled_since: string | null;
238
412
  $transcript_path: string | null;
239
413
  $prompt_version: string | null;
414
+ $claude_session_id: string | null;
240
415
  }
241
416
  >(`
242
417
  INSERT INTO sessions
243
418
  (id, agent_name, capability, worktree_path, branch_name, task_id,
244
419
  tmux_session, state, pid, parent_agent, depth, run_id,
245
420
  started_at, last_activity, escalation_level, stalled_since, transcript_path,
246
- prompt_version)
421
+ prompt_version, claude_session_id)
247
422
  VALUES
248
423
  ($id, $agent_name, $capability, $worktree_path, $branch_name, $task_id,
249
424
  $tmux_session, $state, $pid, $parent_agent, $depth, $run_id,
250
425
  $started_at, $last_activity, $escalation_level, $stalled_since, $transcript_path,
251
- $prompt_version)
426
+ $prompt_version, $claude_session_id)
252
427
  ON CONFLICT(agent_name) DO UPDATE SET
253
428
  id = excluded.id,
254
429
  capability = excluded.capability,
@@ -266,7 +441,8 @@ export function createSessionStore(dbPath: string): SessionStore {
266
441
  escalation_level = excluded.escalation_level,
267
442
  stalled_since = excluded.stalled_since,
268
443
  transcript_path = excluded.transcript_path,
269
- prompt_version = excluded.prompt_version
444
+ prompt_version = excluded.prompt_version,
445
+ claude_session_id = excluded.claude_session_id
270
446
  `);
271
447
 
272
448
  const getByNameStmt = db.prepare<SessionRow, { $agent_name: string }>(`
@@ -274,7 +450,8 @@ export function createSessionStore(dbPath: string): SessionStore {
274
450
  `);
275
451
 
276
452
  const getActiveStmt = db.prepare<SessionRow, Record<string, never>>(`
277
- SELECT * FROM sessions WHERE state IN ('booting', 'working', 'stalled')
453
+ SELECT * FROM sessions
454
+ WHERE state IN ('booting', 'working', 'in_turn', 'between_turns', 'stalled')
278
455
  ORDER BY started_at ASC
279
456
  `);
280
457
 
@@ -290,10 +467,39 @@ export function createSessionStore(dbPath: string): SessionStore {
290
467
  SELECT * FROM sessions WHERE run_id = $run_id ORDER BY started_at ASC
291
468
  `);
292
469
 
470
+ // Clear tmux_session when landing in a terminal state — the tmux session
471
+ // has already been torn down by ov stop / watchdog / coordinator cleanup,
472
+ // so the stored string is stale (overstory-14c0).
473
+ const terminalInList = TERMINAL_STATES.map((s) => `'${s}'`).join(",");
293
474
  const updateStateStmt = db.prepare<void, { $agent_name: string; $state: string }>(`
294
- UPDATE sessions SET state = $state WHERE agent_name = $agent_name
475
+ UPDATE sessions
476
+ SET state = $state,
477
+ tmux_session = CASE WHEN $state IN (${terminalInList}) THEN '' ELSE tmux_session END
478
+ WHERE agent_name = $agent_name
295
479
  `);
296
480
 
481
+ // Per-target-state CAS statements. The IN-list values come from a static
482
+ // matrix we control (TRANSITION_ALLOWED_FROM), so inlining as literals is
483
+ // safe and lets bun:sqlite re-use the prepared plan without dynamic params.
484
+ const tryTransitionStmts = (() => {
485
+ const stmts: Partial<
486
+ Record<AgentState, ReturnType<typeof db.prepare<void, { $agent_name: string }>>>
487
+ > = {};
488
+ const terminalSet = new Set<AgentState>(TERMINAL_STATES);
489
+ for (const target of Object.keys(TRANSITION_ALLOWED_FROM) as AgentState[]) {
490
+ const allowed = TRANSITION_ALLOWED_FROM[target];
491
+ if (allowed.length === 0) continue;
492
+ const inList = allowed.map((s) => `'${s}'`).join(",");
493
+ const setClause = terminalSet.has(target)
494
+ ? `state = '${target}', tmux_session = ''`
495
+ : `state = '${target}'`;
496
+ stmts[target] = db.prepare<void, { $agent_name: string }>(
497
+ `UPDATE sessions SET ${setClause} WHERE agent_name = $agent_name AND state IN (${inList})`,
498
+ );
499
+ }
500
+ return stmts;
501
+ })();
502
+
297
503
  const updateLastActivityStmt = db.prepare<void, { $agent_name: string; $last_activity: string }>(`
298
504
  UPDATE sessions SET last_activity = $last_activity WHERE agent_name = $agent_name
299
505
  `);
@@ -322,6 +528,13 @@ export function createSessionStore(dbPath: string): SessionStore {
322
528
  UPDATE sessions SET transcript_path = $transcript_path WHERE agent_name = $agent_name
323
529
  `);
324
530
 
531
+ const updateClaudeSessionIdStmt = db.prepare<
532
+ void,
533
+ { $agent_name: string; $claude_session_id: string }
534
+ >(`
535
+ UPDATE sessions SET claude_session_id = $claude_session_id WHERE agent_name = $agent_name
536
+ `);
537
+
325
538
  return {
326
539
  upsert(session: AgentSession): void {
327
540
  upsertStmt.run({
@@ -343,6 +556,7 @@ export function createSessionStore(dbPath: string): SessionStore {
343
556
  $stalled_since: session.stalledSince,
344
557
  $transcript_path: session.transcriptPath,
345
558
  $prompt_version: session.promptVersion ?? null,
559
+ $claude_session_id: session.claudeSessionId ?? null,
346
560
  });
347
561
  },
348
562
 
@@ -375,6 +589,37 @@ export function createSessionStore(dbPath: string): SessionStore {
375
589
  updateStateStmt.run({ $agent_name: agentName, $state: state });
376
590
  },
377
591
 
592
+ tryTransitionState(agentName: string, newState: AgentState): TransitionOutcome {
593
+ // Read prev for diagnostic accuracy before the CAS. The read is racy
594
+ // against another writer landing first, but the CAS that follows is
595
+ // authoritative — `changes === 0` means the CAS rejected against
596
+ // whatever the row holds NOW, regardless of what we read here.
597
+ const before = getByNameStmt.get({ $agent_name: agentName });
598
+ if (before === null) {
599
+ return { ok: false, reason: "not_found", attempted: newState };
600
+ }
601
+ const stmt = tryTransitionStmts[newState];
602
+ if (stmt !== undefined) {
603
+ const result = stmt.run({ $agent_name: agentName });
604
+ if (result.changes > 0) {
605
+ return { ok: true, prev: before.state as AgentState, next: newState };
606
+ }
607
+ }
608
+ // CAS rejected (or no stmt for this target, e.g. booting). Re-read to
609
+ // report the state that actually blocked us — another writer may have
610
+ // landed between our `before` read and the CAS.
611
+ const after = getByNameStmt.get({ $agent_name: agentName });
612
+ if (after === null) {
613
+ return { ok: false, reason: "not_found", attempted: newState };
614
+ }
615
+ return {
616
+ ok: false,
617
+ reason: "illegal_transition",
618
+ prev: after.state as AgentState,
619
+ attempted: newState,
620
+ };
621
+ },
622
+
378
623
  updateLastActivity(agentName: string): void {
379
624
  updateLastActivityStmt.run({
380
625
  $agent_name: agentName,
@@ -394,6 +639,10 @@ export function createSessionStore(dbPath: string): SessionStore {
394
639
  updateTranscriptPathStmt.run({ $agent_name: agentName, $transcript_path: path });
395
640
  },
396
641
 
642
+ updateClaudeSessionId(agentName: string, sessionId: string): void {
643
+ updateClaudeSessionIdStmt.run({ $agent_name: agentName, $claude_session_id: sessionId });
644
+ },
645
+
397
646
  remove(agentName: string): void {
398
647
  removeStmt.run({ $agent_name: agentName });
399
648
  },
@@ -473,7 +722,12 @@ export function createRunStore(dbPath: string): RunStore {
473
722
  db.exec("PRAGMA synchronous = NORMAL");
474
723
  db.exec("PRAGMA busy_timeout = 5000");
475
724
 
476
- // Create schema (idempotent — safe if SessionStore already created these)
725
+ // Create schema (idempotent — safe if SessionStore already created these).
726
+ // `agent_count` is derived from the sessions table at read time, so the
727
+ // sessions table must exist when the run-read statements are prepared
728
+ // — even if the caller only opens a RunStore and never opens a SessionStore.
729
+ db.exec(CREATE_TABLE);
730
+ db.exec(CREATE_INDEXES);
477
731
  db.exec(CREATE_RUNS_TABLE);
478
732
 
479
733
  // Migrate: add coordinator_name column BEFORE creating indexes that reference it.
@@ -499,26 +753,35 @@ export function createRunStore(dbPath: string): RunStore {
499
753
  VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $coordinator_name, $status)
500
754
  `);
501
755
 
756
+ // `agent_count` is derived from the sessions table at read time rather than
757
+ // read from the column. The cached column value drifted because only sling
758
+ // incremented it — coordinator startup never did, so for every run with a
759
+ // coordinator the count was off by one (overstory-8e69). Sourcing from
760
+ // sessions makes the count match `SELECT * FROM sessions WHERE run_id = ?`
761
+ // and removes the writer/reader asymmetry. The column is still written so
762
+ // older overstory binaries pointed at the same db can keep functioning.
763
+ const RUN_COLUMNS = `
764
+ id, started_at, completed_at,
765
+ (SELECT COUNT(*) FROM sessions WHERE sessions.run_id = runs.id) AS agent_count,
766
+ coordinator_session_id, coordinator_name, status
767
+ `;
768
+
502
769
  const getRunStmt = db.prepare<RunRow, { $id: string }>(`
503
- SELECT * FROM runs WHERE id = $id
770
+ SELECT ${RUN_COLUMNS} FROM runs WHERE id = $id
504
771
  `);
505
772
 
506
773
  const getActiveRunStmt = db.prepare<RunRow, Record<string, never>>(`
507
- SELECT * FROM runs WHERE status = 'active'
774
+ SELECT ${RUN_COLUMNS} FROM runs WHERE status = 'active'
508
775
  ORDER BY started_at DESC
509
776
  LIMIT 1
510
777
  `);
511
778
 
512
779
  const getActiveRunForCoordinatorStmt = db.prepare<RunRow, { $coordinator_name: string }>(`
513
- SELECT * FROM runs WHERE status = 'active' AND coordinator_name = $coordinator_name
780
+ SELECT ${RUN_COLUMNS} FROM runs WHERE status = 'active' AND coordinator_name = $coordinator_name
514
781
  ORDER BY started_at DESC
515
782
  LIMIT 1
516
783
  `);
517
784
 
518
- const incrementAgentCountStmt = db.prepare<void, { $id: string }>(`
519
- UPDATE runs SET agent_count = agent_count + 1 WHERE id = $id
520
- `);
521
-
522
785
  const completeRunStmt = db.prepare<
523
786
  void,
524
787
  { $id: string; $status: string; $completed_at: string }
@@ -565,15 +828,15 @@ export function createRunStore(dbPath: string): RunStore {
565
828
 
566
829
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
567
830
  const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
568
- const query = `SELECT * FROM runs ${whereClause} ORDER BY started_at DESC ${limitClause}`;
831
+ const query = `SELECT ${RUN_COLUMNS} FROM runs ${whereClause} ORDER BY started_at DESC ${limitClause}`;
569
832
 
570
833
  const rows = db.prepare<RunRow, Record<string, string | number>>(query).all(params);
571
834
  return rows.map(rowToRun);
572
835
  },
573
836
 
574
- incrementAgentCount(runId: string): void {
575
- incrementAgentCountStmt.run({ $id: runId });
576
- },
837
+ // Kept for API stability but a no-op: `agent_count` is now derived from
838
+ // the sessions table on every read (see RUN_COLUMNS above).
839
+ incrementAgentCount(_runId: string): void {},
577
840
 
578
841
  completeRun(runId: string, status: "completed" | "failed"): void {
579
842
  completeRunStmt.run({
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Regression test for overstory-6d42: bun test must not be redirectable to a
3
+ * real .overstory/ via inherited OVERSTORY_PROJECT_ROOT (or sibling) env vars.
4
+ *
5
+ * The preload in bunfig.toml runs src/test-setup.ts before any test loads,
6
+ * deleting OVERSTORY_* env vars and clearing the project-root override. By
7
+ * the time this test executes, those values must already be gone — even if a
8
+ * worker agent's environment had them set when bun test was invoked.
9
+ */
10
+
11
+ import { expect, test } from "bun:test";
12
+ import { getProjectRootOverride } from "./config.ts";
13
+
14
+ const ENV_KEYS = [
15
+ "OVERSTORY_PROJECT_ROOT",
16
+ "OVERSTORY_AGENT_NAME",
17
+ "OVERSTORY_WORKTREE_PATH",
18
+ "OVERSTORY_TASK_ID",
19
+ "OVERSTORY_PROFILE",
20
+ "OVERSTORY_RUN_ID",
21
+ ] as const;
22
+
23
+ for (const key of ENV_KEYS) {
24
+ test(`${key} is unset by the test preload`, () => {
25
+ expect(process.env[key]).toBeUndefined();
26
+ });
27
+ }
28
+
29
+ test("project-root override is cleared by the test preload", () => {
30
+ expect(getProjectRootOverride()).toBeUndefined();
31
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Global test preload (referenced from bunfig.toml [test] preload).
3
+ *
4
+ * Prevents test runs from leaking into a real .overstory/ when bun test is
5
+ * executed inside an agent worktree (where ov sling injects OVERSTORY_PROJECT_ROOT
6
+ * into the spawned process — see src/commands/sling.ts:928).
7
+ *
8
+ * Without this preload, resolveProjectRoot() short-circuits to the env var
9
+ * before consulting the per-test temp dir, so tests calling cleanCommand,
10
+ * coordinatorCommand, mailCommand, etc. silently target the live project.
11
+ * That's how overstory-6d42 contamination occurred: a worker agent ran
12
+ * bun test, clean.test.ts wiped the live .overstory/, coordinator.test.ts
13
+ * left dozens of bogus runs, and mail.test.ts inserted fixture messages.
14
+ *
15
+ * Tests that need OVERSTORY_PROJECT_ROOT set (e.g. config.test.ts) set it
16
+ * explicitly inside the test body and restore it in afterEach.
17
+ */
18
+
19
+ import { clearProjectRootOverride } from "./config.ts";
20
+
21
+ delete process.env.OVERSTORY_PROJECT_ROOT;
22
+ delete process.env.OVERSTORY_AGENT_NAME;
23
+ delete process.env.OVERSTORY_WORKTREE_PATH;
24
+ delete process.env.OVERSTORY_TASK_ID;
25
+ delete process.env.OVERSTORY_PROFILE;
26
+ delete process.env.OVERSTORY_RUN_ID;
27
+
28
+ clearProjectRootOverride();