@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.
- package/README.md +13 -9
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +494 -6
- package/src/commands/coordinator.ts +200 -4
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +93 -15
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +416 -358
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/supervisor.ts +2 -0
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +383 -25
- package/src/merge/resolver.ts +291 -98
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +64 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +25 -1
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +66 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
- package/src/worktree/tmux.test.ts +150 -0
- 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", () => {
|
package/src/sessions/store.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|