@os-eco/overstory-cli 0.8.5 → 0.8.7

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 (53) hide show
  1. package/README.md +13 -9
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/hooks-deployer.test.ts +185 -12
  5. package/src/agents/hooks-deployer.ts +57 -1
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +494 -6
  9. package/src/commands/coordinator.ts +200 -4
  10. package/src/commands/dashboard.ts +84 -18
  11. package/src/commands/ecosystem.test.ts +101 -0
  12. package/src/commands/init.test.ts +211 -0
  13. package/src/commands/init.ts +93 -15
  14. package/src/commands/log.test.ts +10 -11
  15. package/src/commands/log.ts +31 -32
  16. package/src/commands/prime.ts +30 -5
  17. package/src/commands/sling.test.ts +33 -0
  18. package/src/commands/sling.ts +416 -358
  19. package/src/commands/spec.ts +8 -2
  20. package/src/commands/stop.test.ts +127 -6
  21. package/src/commands/stop.ts +95 -43
  22. package/src/commands/supervisor.ts +2 -0
  23. package/src/commands/watch.ts +29 -9
  24. package/src/config.test.ts +72 -0
  25. package/src/config.ts +26 -1
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +383 -25
  28. package/src/merge/resolver.ts +291 -98
  29. package/src/runtimes/claude.test.ts +32 -7
  30. package/src/runtimes/claude.ts +19 -4
  31. package/src/runtimes/codex.test.ts +13 -0
  32. package/src/runtimes/codex.ts +18 -2
  33. package/src/runtimes/copilot.ts +3 -0
  34. package/src/runtimes/cursor.test.ts +497 -0
  35. package/src/runtimes/cursor.ts +205 -0
  36. package/src/runtimes/gemini.ts +3 -0
  37. package/src/runtimes/opencode.ts +3 -0
  38. package/src/runtimes/pi.test.ts +119 -2
  39. package/src/runtimes/pi.ts +64 -12
  40. package/src/runtimes/registry.test.ts +21 -1
  41. package/src/runtimes/registry.ts +3 -0
  42. package/src/runtimes/sapling.ts +3 -0
  43. package/src/runtimes/types.ts +5 -0
  44. package/src/schema-consistency.test.ts +1 -0
  45. package/src/sessions/store.test.ts +178 -0
  46. package/src/sessions/store.ts +44 -8
  47. package/src/types.ts +25 -1
  48. package/src/watchdog/daemon.test.ts +257 -0
  49. package/src/watchdog/daemon.ts +66 -23
  50. package/src/worktree/manager.test.ts +65 -1
  51. package/src/worktree/manager.ts +36 -0
  52. package/src/worktree/tmux.test.ts +150 -0
  53. package/src/worktree/tmux.ts +126 -23
@@ -587,6 +587,48 @@ describe("edge cases", () => {
587
587
  });
588
588
  });
589
589
 
590
+ // ============================================================
591
+ // SessionStore migration: coordinator_name in runs table
592
+ // ============================================================
593
+
594
+ describe("createSessionStore migrates runs table coordinator_name", () => {
595
+ test("opens successfully when existing runs table lacks coordinator_name", async () => {
596
+ // Close the store created by beforeEach so we can recreate the DB manually.
597
+ store.close();
598
+
599
+ const { Database: Db } = await import("bun:sqlite");
600
+ const legacyDb = new Db(dbPath);
601
+ // Drop runs table and recreate WITHOUT coordinator_name (simulates pre-migration DB).
602
+ legacyDb.exec("DROP TABLE IF EXISTS runs");
603
+ legacyDb.exec(`
604
+ CREATE TABLE runs (
605
+ id TEXT PRIMARY KEY,
606
+ started_at TEXT NOT NULL,
607
+ completed_at TEXT,
608
+ agent_count INTEGER NOT NULL DEFAULT 0,
609
+ coordinator_session_id TEXT,
610
+ status TEXT NOT NULL DEFAULT 'active'
611
+ )
612
+ `);
613
+ legacyDb.exec(
614
+ "INSERT INTO runs (id, started_at, status) VALUES ('legacy-run', '2026-01-01T00:00:00.000Z', 'active')",
615
+ );
616
+ legacyDb.close();
617
+
618
+ // createSessionStore should migrate the table and create indexes without error.
619
+ const migratedStore = createSessionStore(dbPath);
620
+ try {
621
+ // Verify the store works (no "no such column" error).
622
+ expect(migratedStore.getAll()).toBeArray();
623
+ } finally {
624
+ migratedStore.close();
625
+ }
626
+
627
+ // Re-assign so afterEach cleanup works.
628
+ store = createSessionStore(join(tempDir, "unused.db"));
629
+ });
630
+ });
631
+
590
632
  // ============================================================
591
633
  // RunStore Tests
592
634
  // ============================================================
@@ -892,6 +934,142 @@ describe("RunStore", () => {
892
934
  });
893
935
  });
894
936
 
937
+ // === coordinatorName ===
938
+
939
+ describe("coordinatorName", () => {
940
+ test("creates run with coordinatorName and retrieves it", () => {
941
+ runStore.createRun(makeRun({ coordinatorName: "coordinator" }));
942
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
943
+ expect(result?.coordinatorName).toBe("coordinator");
944
+ });
945
+
946
+ test("creates run with null coordinatorName", () => {
947
+ runStore.createRun(makeRun({ coordinatorName: null }));
948
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
949
+ expect(result?.coordinatorName).toBeNull();
950
+ });
951
+
952
+ test("creates run without coordinatorName defaults to null", () => {
953
+ runStore.createRun(makeRun());
954
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
955
+ expect(result?.coordinatorName).toBeNull();
956
+ });
957
+ });
958
+
959
+ // === getActiveRunForCoordinator ===
960
+
961
+ describe("getActiveRunForCoordinator", () => {
962
+ test("returns the active run for the given coordinator", () => {
963
+ runStore.createRun(
964
+ makeRun({
965
+ id: "run-coord-a",
966
+ coordinatorName: "coordinator-a",
967
+ startedAt: "2026-02-13T10:00:00.000Z",
968
+ }),
969
+ );
970
+ runStore.createRun(
971
+ makeRun({
972
+ id: "run-coord-b",
973
+ coordinatorName: "coordinator-b",
974
+ startedAt: "2026-02-13T11:00:00.000Z",
975
+ }),
976
+ );
977
+
978
+ const result = runStore.getActiveRunForCoordinator("coordinator-a");
979
+ expect(result?.id).toBe("run-coord-a");
980
+ });
981
+
982
+ test("returns null when no active run for coordinator", () => {
983
+ runStore.createRun(makeRun({ id: "run-coord-a", coordinatorName: "coordinator-a" }));
984
+ runStore.completeRun("run-coord-a", "completed");
985
+
986
+ const result = runStore.getActiveRunForCoordinator("coordinator-a");
987
+ expect(result).toBeNull();
988
+ });
989
+
990
+ test("returns null for unknown coordinator", () => {
991
+ runStore.createRun(makeRun({ id: "run-coord-a", coordinatorName: "coordinator-a" }));
992
+ const result = runStore.getActiveRunForCoordinator("other-coordinator");
993
+ expect(result).toBeNull();
994
+ });
995
+
996
+ test("returns most recent active run when coordinator has multiple", () => {
997
+ runStore.createRun(
998
+ makeRun({
999
+ id: "run-early",
1000
+ coordinatorName: "coordinator",
1001
+ startedAt: "2026-02-13T08:00:00.000Z",
1002
+ }),
1003
+ );
1004
+ runStore.createRun(
1005
+ makeRun({
1006
+ id: "run-late",
1007
+ coordinatorName: "coordinator",
1008
+ startedAt: "2026-02-13T12:00:00.000Z",
1009
+ }),
1010
+ );
1011
+
1012
+ const result = runStore.getActiveRunForCoordinator("coordinator");
1013
+ expect(result?.id).toBe("run-late");
1014
+ });
1015
+
1016
+ test("ignores runs for other coordinators", () => {
1017
+ runStore.createRun(makeRun({ id: "run-a", coordinatorName: "coordinator-a" }));
1018
+ runStore.createRun(
1019
+ makeRun({
1020
+ id: "run-b",
1021
+ coordinatorName: "coordinator-b",
1022
+ startedAt: "2026-02-13T11:00:00.000Z",
1023
+ }),
1024
+ );
1025
+
1026
+ const result = runStore.getActiveRunForCoordinator("coordinator-a");
1027
+ expect(result?.id).toBe("run-a");
1028
+ expect(result?.coordinatorName).toBe("coordinator-a");
1029
+ });
1030
+ });
1031
+
1032
+ // === migration: coordinator_name column ===
1033
+
1034
+ describe("migration: coordinator_name column", () => {
1035
+ test("adds coordinator_name column to existing runs table without it", async () => {
1036
+ // Create a store, close it, then manually drop the coordinator_name column
1037
+ // by creating a fresh DB without it, simulating a pre-migration schema.
1038
+ runStore.close();
1039
+
1040
+ const { Database: Db } = await import("bun:sqlite");
1041
+ const legacyDb = new Db(dbPath);
1042
+ legacyDb.exec("DROP TABLE IF EXISTS runs");
1043
+ legacyDb.exec(`
1044
+ CREATE TABLE runs (
1045
+ id TEXT PRIMARY KEY,
1046
+ started_at TEXT NOT NULL,
1047
+ completed_at TEXT,
1048
+ agent_count INTEGER NOT NULL DEFAULT 0,
1049
+ coordinator_session_id TEXT,
1050
+ status TEXT NOT NULL DEFAULT 'active'
1051
+ )
1052
+ `);
1053
+ legacyDb.exec(
1054
+ "INSERT INTO runs (id, started_at, status) VALUES ('legacy-run', '2026-01-01T00:00:00.000Z', 'active')",
1055
+ );
1056
+ legacyDb.close();
1057
+
1058
+ // Opening a new RunStore should run the migration and add coordinator_name
1059
+ const migratedStore = createRunStore(dbPath);
1060
+ try {
1061
+ const run = migratedStore.getRun("legacy-run");
1062
+ expect(run).not.toBeNull();
1063
+ expect(run?.coordinatorName).toBeNull();
1064
+ } finally {
1065
+ migratedStore.close();
1066
+ }
1067
+
1068
+ // Re-assign store so afterEach cleanup doesn't double-close
1069
+ runStore = createRunStore(join(tempDir, "unused-run.db"));
1070
+ });
1071
+ });
1072
+
895
1073
  // === close ===
896
1074
 
897
1075
  describe("close", () => {
@@ -66,6 +66,7 @@ interface RunRow {
66
66
  completed_at: string | null;
67
67
  agent_count: number;
68
68
  coordinator_session_id: string | null;
69
+ coordinator_name: string | null;
69
70
  status: string;
70
71
  }
71
72
 
@@ -102,12 +103,14 @@ CREATE TABLE IF NOT EXISTS runs (
102
103
  completed_at TEXT,
103
104
  agent_count INTEGER NOT NULL DEFAULT 0,
104
105
  coordinator_session_id TEXT,
106
+ coordinator_name TEXT,
105
107
  status TEXT NOT NULL DEFAULT 'active'
106
108
  CHECK(status IN ('active','completed','failed'))
107
109
  )`;
108
110
 
109
111
  const CREATE_RUNS_INDEXES = `
110
- CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)`;
112
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
113
+ CREATE INDEX IF NOT EXISTS idx_runs_coordinator ON runs(coordinator_name)`;
111
114
 
112
115
  /** Convert a database row (snake_case) to an AgentSession object (camelCase). */
113
116
  function rowToSession(row: SessionRow): AgentSession {
@@ -140,6 +143,7 @@ function rowToRun(row: RunRow): Run {
140
143
  completedAt: row.completed_at,
141
144
  agentCount: row.agent_count,
142
145
  coordinatorSessionId: row.coordinator_session_id,
146
+ coordinatorName: row.coordinator_name,
143
147
  status: row.status as RunStatus,
144
148
  };
145
149
  }
@@ -182,16 +186,18 @@ export function createSessionStore(dbPath: string): SessionStore {
182
186
  db.exec("PRAGMA synchronous = NORMAL");
183
187
  db.exec("PRAGMA busy_timeout = 5000");
184
188
 
185
- // Create schema
189
+ // Create schema (tables first, then migrations, then indexes)
186
190
  db.exec(CREATE_TABLE);
187
- db.exec(CREATE_INDEXES);
188
191
  db.exec(CREATE_RUNS_TABLE);
189
- db.exec(CREATE_RUNS_INDEXES);
190
192
 
191
- // Migrate: rename bead_id task_id on existing tables
193
+ // Migrate existing tables BEFORE creating indexes that reference new columns.
192
194
  migrateBeadIdToTaskId(db);
193
- // Migrate: add transcript_path column to existing tables
194
195
  migrateAddTranscriptPath(db);
196
+ migrateAddCoordinatorName(db);
197
+
198
+ // Now safe to create indexes (all columns exist).
199
+ db.exec(CREATE_INDEXES);
200
+ db.exec(CREATE_RUNS_INDEXES);
195
201
 
196
202
  // Prepare statements for frequent operations
197
203
  const upsertStmt = db.prepare<
@@ -420,6 +426,18 @@ export function createSessionStore(dbPath: string): SessionStore {
420
426
  };
421
427
  }
422
428
 
429
+ /**
430
+ * Migrate an existing runs table to add the coordinator_name column.
431
+ * Safe to call multiple times — only adds the column if it does not exist.
432
+ */
433
+ function migrateAddCoordinatorName(db: Database): void {
434
+ const rows = db.prepare("PRAGMA table_info(runs)").all() as Array<{ name: string }>;
435
+ const existingColumns = new Set(rows.map((r) => r.name));
436
+ if (!existingColumns.has("coordinator_name")) {
437
+ db.exec("ALTER TABLE runs ADD COLUMN coordinator_name TEXT");
438
+ }
439
+ }
440
+
423
441
  /**
424
442
  * Create a new RunStore backed by a SQLite database at the given path.
425
443
  *
@@ -436,6 +454,11 @@ export function createRunStore(dbPath: string): RunStore {
436
454
 
437
455
  // Create schema (idempotent — safe if SessionStore already created these)
438
456
  db.exec(CREATE_RUNS_TABLE);
457
+
458
+ // Migrate: add coordinator_name column BEFORE creating indexes that reference it.
459
+ // The migration is a no-op on new databases (column already in CREATE_RUNS_TABLE).
460
+ migrateAddCoordinatorName(db);
461
+
439
462
  db.exec(CREATE_RUNS_INDEXES);
440
463
 
441
464
  // Prepare statements for frequent operations
@@ -447,11 +470,12 @@ export function createRunStore(dbPath: string): RunStore {
447
470
  $completed_at: string | null;
448
471
  $agent_count: number;
449
472
  $coordinator_session_id: string | null;
473
+ $coordinator_name: string | null;
450
474
  $status: string;
451
475
  }
452
476
  >(`
453
- INSERT INTO runs (id, started_at, completed_at, agent_count, coordinator_session_id, status)
454
- VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $status)
477
+ INSERT INTO runs (id, started_at, completed_at, agent_count, coordinator_session_id, coordinator_name, status)
478
+ VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $coordinator_name, $status)
455
479
  `);
456
480
 
457
481
  const getRunStmt = db.prepare<RunRow, { $id: string }>(`
@@ -464,6 +488,12 @@ export function createRunStore(dbPath: string): RunStore {
464
488
  LIMIT 1
465
489
  `);
466
490
 
491
+ const getActiveRunForCoordinatorStmt = db.prepare<RunRow, { $coordinator_name: string }>(`
492
+ SELECT * FROM runs WHERE status = 'active' AND coordinator_name = $coordinator_name
493
+ ORDER BY started_at DESC
494
+ LIMIT 1
495
+ `);
496
+
467
497
  const incrementAgentCountStmt = db.prepare<void, { $id: string }>(`
468
498
  UPDATE runs SET agent_count = agent_count + 1 WHERE id = $id
469
499
  `);
@@ -483,6 +513,7 @@ export function createRunStore(dbPath: string): RunStore {
483
513
  $completed_at: null,
484
514
  $agent_count: run.agentCount ?? 0,
485
515
  $coordinator_session_id: run.coordinatorSessionId,
516
+ $coordinator_name: run.coordinatorName ?? null,
486
517
  $status: run.status,
487
518
  });
488
519
  },
@@ -497,6 +528,11 @@ export function createRunStore(dbPath: string): RunStore {
497
528
  return row ? rowToRun(row) : null;
498
529
  },
499
530
 
531
+ getActiveRunForCoordinator(coordinatorName: string): Run | null {
532
+ const row = getActiveRunForCoordinatorStmt.get({ $coordinator_name: coordinatorName });
533
+ return row ? rowToRun(row) : null;
534
+ },
535
+
500
536
  listRuns(opts?: { limit?: number; status?: RunStatus }): Run[] {
501
537
  const conditions: string[] = [];
502
538
  const params: Record<string, string | number> = {};
package/src/types.ts CHANGED
@@ -39,6 +39,19 @@ export type TaskTrackerBackend = "auto" | "seeds" | "beads";
39
39
 
40
40
  // === Project Configuration ===
41
41
 
42
+ /**
43
+ * Conditions that trigger automatic coordinator shutdown.
44
+ * All triggers default to false for backward compatibility.
45
+ */
46
+ export interface CoordinatorExitTriggers {
47
+ /** Exit when all spawned agents have completed and their branches have been merged. */
48
+ allAgentsDone: boolean;
49
+ /** Exit when the task tracker reports no unblocked work (sd/bd ready returns empty). */
50
+ taskTrackerEmpty: boolean;
51
+ /** Exit when a typed shutdown mail is received from an external caller (e.g., greenhouse). */
52
+ onShutdownSignal: boolean;
53
+ }
54
+
42
55
  /** A single quality gate command that agents must pass before reporting completion. */
43
56
  export interface QualityGate {
44
57
  /** Display name shown in the overlay (e.g., "Tests"). */
@@ -96,6 +109,10 @@ export interface OverstoryConfig {
96
109
  verbose: boolean;
97
110
  redactSecrets: boolean;
98
111
  };
112
+ coordinator?: {
113
+ /** Conditions that trigger automatic coordinator shutdown. */
114
+ exitTriggers: CoordinatorExitTriggers;
115
+ };
99
116
  runtime?: {
100
117
  /** Default runtime adapter name (default: "claude"). */
101
118
  default: string;
@@ -374,6 +391,8 @@ export interface MergeResult {
374
391
  tier: ResolutionTier;
375
392
  conflictFiles: string[];
376
393
  errorMessage: string | null;
394
+ /** Warnings about files where auto-resolve was skipped to prevent content loss. */
395
+ warnings: string[];
377
396
  }
378
397
 
379
398
  /** Parsed conflict pattern from a single mulch record. */
@@ -569,12 +588,15 @@ export interface Run {
569
588
  completedAt: string | null;
570
589
  agentCount: number;
571
590
  coordinatorSessionId: string | null;
591
+ coordinatorName: string | null; // which coordinator owns this run
572
592
  status: RunStatus;
573
593
  }
574
594
 
575
595
  /** Input for creating a new run. */
576
- export type InsertRun = Omit<Run, "completedAt" | "agentCount"> & {
596
+ export type InsertRun = Omit<Run, "completedAt" | "agentCount" | "coordinatorName"> & {
577
597
  agentCount?: number;
598
+ /** Which coordinator owns this run. Defaults to null when not provided. */
599
+ coordinatorName?: string | null;
578
600
  };
579
601
 
580
602
  /** Interface for run management operations. */
@@ -585,6 +607,8 @@ export interface RunStore {
585
607
  getRun(id: string): Run | null;
586
608
  /** Get the most recently started active run. */
587
609
  getActiveRun(): Run | null;
610
+ /** Get the most recently started active run for a specific coordinator. */
611
+ getActiveRunForCoordinator(coordinatorName: string): Run | null;
588
612
  /** List runs, optionally limited. */
589
613
  listRuns(opts?: { limit?: number; status?: RunStatus }): Run[];
590
614
  /** Increment agent count for a run. */
@@ -1977,3 +1977,260 @@ describe("buildCompletionMessage", () => {
1977
1977
  expect(msg).toContain("3");
1978
1978
  });
1979
1979
  });
1980
+
1981
+ // === Bug fix tests: headless agent kill blast radius + stale detection ===
1982
+
1983
+ describe("headless agent kill blast radius fix (Bug 1)", () => {
1984
+ /**
1985
+ * Track PID kill calls without spawning real processes.
1986
+ * Also surfaces killTree calls so tests can assert on them.
1987
+ */
1988
+ function processTracker(): {
1989
+ isAlive: (pid: number) => boolean;
1990
+ killTree: (pid: number) => Promise<void>;
1991
+ killed: number[];
1992
+ } {
1993
+ const killed: number[] = [];
1994
+ return {
1995
+ isAlive: (pid: number) => {
1996
+ try {
1997
+ process.kill(pid, 0);
1998
+ return true;
1999
+ } catch {
2000
+ return false;
2001
+ }
2002
+ },
2003
+ killTree: async (pid: number) => {
2004
+ killed.push(pid);
2005
+ },
2006
+ killed,
2007
+ };
2008
+ }
2009
+
2010
+ test("headless agent at escalation level 3 kills PID, not tmux session", async () => {
2011
+ const nudgeIntervalMs = 60_000;
2012
+ // stalledSince is 4 intervals ago — expectedLevel = floor(4) = 4, clamped to MAX (3)
2013
+ const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
2014
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2015
+
2016
+ const session = makeSession({
2017
+ agentName: "headless-stalled",
2018
+ tmuxSession: "", // headless
2019
+ pid: process.pid, // alive PID — ZFC won't trigger direct terminate
2020
+ state: "stalled",
2021
+ lastActivity: staleActivity,
2022
+ escalationLevel: 2,
2023
+ stalledSince,
2024
+ });
2025
+
2026
+ writeSessionsToStore(tempRoot, [session]);
2027
+
2028
+ const proc = processTracker();
2029
+ // tmux mock: isSessionAlive("") returns true — simulates prefix-match bug scenario
2030
+ const tmuxMock = tmuxWithLiveness({ "": true });
2031
+
2032
+ await runDaemonTick({
2033
+ root: tempRoot,
2034
+ ...THRESHOLDS,
2035
+ nudgeIntervalMs,
2036
+ tier1Enabled: false,
2037
+ _tmux: tmuxMock,
2038
+ _triage: triageAlways("extend"),
2039
+ _process: proc,
2040
+ _eventStore: null,
2041
+ _recordFailure: async () => {},
2042
+ _getConnection: () => undefined,
2043
+ _removeConnection: () => {},
2044
+ _tailerRegistry: new Map(),
2045
+ _findLatestStdoutLog: async () => null,
2046
+ });
2047
+
2048
+ // PID was killed via killTree, NOT via tmux killSession("")
2049
+ expect(proc.killed).toContain(process.pid);
2050
+ expect(tmuxMock.killed).not.toContain("");
2051
+ });
2052
+
2053
+ test("headless agent direct terminate kills PID, not tmux", async () => {
2054
+ // PID 999999 is virtually guaranteed not to exist — health check sees it as dead
2055
+ const deadPid = 999999;
2056
+ const session = makeSession({
2057
+ agentName: "headless-dead-pid",
2058
+ tmuxSession: "", // headless
2059
+ pid: deadPid,
2060
+ state: "working",
2061
+ lastActivity: new Date().toISOString(),
2062
+ });
2063
+
2064
+ writeSessionsToStore(tempRoot, [session]);
2065
+
2066
+ const proc = processTracker();
2067
+ // tmux mock: isSessionAlive("") returns true — would kill everything without the fix
2068
+ const tmuxMock = tmuxWithLiveness({ "": true });
2069
+
2070
+ await runDaemonTick({
2071
+ root: tempRoot,
2072
+ ...THRESHOLDS,
2073
+ _tmux: tmuxMock,
2074
+ _triage: triageAlways("extend"),
2075
+ _process: proc,
2076
+ _eventStore: null,
2077
+ _recordFailure: async () => {},
2078
+ _getConnection: () => undefined,
2079
+ _removeConnection: () => {},
2080
+ _tailerRegistry: new Map(),
2081
+ _findLatestStdoutLog: async () => null,
2082
+ });
2083
+
2084
+ // Should have attempted PID kill, NOT tmux killSession("")
2085
+ expect(proc.killed).toContain(deadPid);
2086
+ expect(tmuxMock.killed).not.toContain("");
2087
+ });
2088
+
2089
+ test("triage terminate on headless agent kills PID, not tmux", async () => {
2090
+ const nudgeIntervalMs = 60_000;
2091
+ // stalledSince is 2.5 intervals ago — expectedLevel = floor(2.5) = 2 → triage fires
2092
+ const stalledSince = new Date(Date.now() - 2.5 * nudgeIntervalMs).toISOString();
2093
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2094
+
2095
+ const session = makeSession({
2096
+ agentName: "headless-triage-terminate",
2097
+ tmuxSession: "", // headless
2098
+ pid: process.pid, // alive
2099
+ state: "stalled",
2100
+ lastActivity: staleActivity,
2101
+ escalationLevel: 1,
2102
+ stalledSince,
2103
+ });
2104
+
2105
+ writeSessionsToStore(tempRoot, [session]);
2106
+
2107
+ const proc = processTracker();
2108
+ const tmuxMock = tmuxWithLiveness({ "": true });
2109
+
2110
+ await runDaemonTick({
2111
+ root: tempRoot,
2112
+ ...THRESHOLDS,
2113
+ nudgeIntervalMs,
2114
+ tier1Enabled: true,
2115
+ _tmux: tmuxMock,
2116
+ _triage: triageAlways("terminate"), // AI triage says terminate
2117
+ _nudge: nudgeTracker().nudge,
2118
+ _process: proc,
2119
+ _eventStore: null,
2120
+ _recordFailure: async () => {},
2121
+ _getConnection: () => undefined,
2122
+ _removeConnection: () => {},
2123
+ _tailerRegistry: new Map(),
2124
+ _findLatestStdoutLog: async () => null,
2125
+ });
2126
+
2127
+ // Should have killed the PID, not the tmux session
2128
+ expect(proc.killed).toContain(process.pid);
2129
+ expect(tmuxMock.killed).not.toContain("");
2130
+ });
2131
+ });
2132
+
2133
+ describe("headless agent stale detection via events.db (Bug 2)", () => {
2134
+ test("headless agent with recent events in events.db is not flagged stale", async () => {
2135
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2136
+
2137
+ const session = makeSession({
2138
+ agentName: "headless-active",
2139
+ tmuxSession: "", // headless
2140
+ pid: process.pid, // alive
2141
+ state: "working",
2142
+ lastActivity: staleActivity, // stale — would trigger escalate without event fallback
2143
+ });
2144
+
2145
+ writeSessionsToStore(tempRoot, [session]);
2146
+
2147
+ const eventsDbPath = join(tempRoot, ".overstory", "events.db");
2148
+ const eventStore = createEventStore(eventsDbPath);
2149
+
2150
+ try {
2151
+ // Insert a recent event for this agent (within the stale threshold window)
2152
+ eventStore.insert({
2153
+ runId: null,
2154
+ agentName: "headless-active",
2155
+ sessionId: null,
2156
+ eventType: "tool_end",
2157
+ toolName: "Read",
2158
+ toolArgs: null,
2159
+ toolDurationMs: 100,
2160
+ level: "info",
2161
+ data: null,
2162
+ });
2163
+
2164
+ const checks: HealthCheck[] = [];
2165
+
2166
+ await runDaemonTick({
2167
+ root: tempRoot,
2168
+ ...THRESHOLDS,
2169
+ onHealthCheck: (c) => checks.push(c),
2170
+ _tmux: tmuxAllAlive(),
2171
+ _triage: triageAlways("extend"),
2172
+ _process: { isAlive: () => true, killTree: async () => {} },
2173
+ _eventStore: eventStore,
2174
+ _recordFailure: async () => {},
2175
+ _getConnection: () => undefined,
2176
+ _removeConnection: () => {},
2177
+ _tailerRegistry: new Map(),
2178
+ _findLatestStdoutLog: async () => null,
2179
+ });
2180
+
2181
+ // Recent events found — lastActivity was refreshed, agent is NOT stalled
2182
+ expect(checks).toHaveLength(1);
2183
+ expect(checks[0]?.action).toBe("none");
2184
+ expect(checks[0]?.state).toBe("working");
2185
+
2186
+ const reloaded = readSessionsFromStore(tempRoot);
2187
+ expect(reloaded[0]?.state).toBe("working");
2188
+ } finally {
2189
+ eventStore.close();
2190
+ }
2191
+ });
2192
+
2193
+ test("headless agent with no recent events IS flagged stale", async () => {
2194
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2195
+
2196
+ const session = makeSession({
2197
+ agentName: "headless-silent",
2198
+ tmuxSession: "", // headless
2199
+ pid: process.pid, // alive
2200
+ state: "working",
2201
+ lastActivity: staleActivity, // stale
2202
+ });
2203
+
2204
+ writeSessionsToStore(tempRoot, [session]);
2205
+
2206
+ const eventsDbPath = join(tempRoot, ".overstory", "events.db");
2207
+ const eventStore = createEventStore(eventsDbPath);
2208
+
2209
+ try {
2210
+ // No events inserted for this agent — event fallback finds nothing
2211
+
2212
+ const checks: HealthCheck[] = [];
2213
+
2214
+ await runDaemonTick({
2215
+ root: tempRoot,
2216
+ ...THRESHOLDS,
2217
+ onHealthCheck: (c) => checks.push(c),
2218
+ _tmux: tmuxAllAlive(),
2219
+ _triage: triageAlways("extend"),
2220
+ _process: { isAlive: () => true, killTree: async () => {} },
2221
+ _eventStore: eventStore,
2222
+ _recordFailure: async () => {},
2223
+ _getConnection: () => undefined,
2224
+ _removeConnection: () => {},
2225
+ _tailerRegistry: new Map(),
2226
+ _findLatestStdoutLog: async () => null,
2227
+ });
2228
+
2229
+ // No recent events — lastActivity stays stale, agent IS flagged stalled
2230
+ expect(checks).toHaveLength(1);
2231
+ expect(checks[0]?.action).toBe("escalate");
2232
+ } finally {
2233
+ eventStore.close();
2234
+ }
2235
+ });
2236
+ });