@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.
- package/README.md +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +219 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
package/src/commands/agents.ts
CHANGED
|
@@ -166,11 +166,20 @@ export async function discoverAgents(
|
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
168
|
* Format the state icon for display.
|
|
169
|
+
*
|
|
170
|
+
* `in_turn` and `between_turns` (overstory-3087) render with the same cyan
|
|
171
|
+
* accent as `working` so a spawn-per-turn worker is visually grouped with
|
|
172
|
+
* other healthy/active agents in `ov agents` output. They use distinct
|
|
173
|
+
* glyphs ('>' vs '~') to mirror the dashboard / theme.ts mapping.
|
|
169
174
|
*/
|
|
170
175
|
function getStateIcon(state: string): string {
|
|
171
176
|
switch (state) {
|
|
172
177
|
case "working":
|
|
173
178
|
return color.cyan(">");
|
|
179
|
+
case "in_turn":
|
|
180
|
+
return color.cyan(">");
|
|
181
|
+
case "between_turns":
|
|
182
|
+
return color.cyan("~");
|
|
174
183
|
case "booting":
|
|
175
184
|
return color.green("-");
|
|
176
185
|
case "stalled":
|
package/src/commands/clean.ts
CHANGED
|
@@ -124,6 +124,7 @@ async function logSyntheticSessionEndEvents(overstoryDir: string): Promise<numbe
|
|
|
124
124
|
interface CleanResult {
|
|
125
125
|
sessionEndEventsLogged: number;
|
|
126
126
|
tmuxKilled: number;
|
|
127
|
+
orphanPidsReaped: number;
|
|
127
128
|
worktreesCleaned: number;
|
|
128
129
|
branchesDeleted: number;
|
|
129
130
|
mailWiped: boolean;
|
|
@@ -217,6 +218,45 @@ function loadRegisteredTmuxNames(overstoryDir: string): Set<string> | null {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Reap any spawn PIDs in sessions.db that survived tmux teardown.
|
|
223
|
+
*
|
|
224
|
+
* `killAllTmuxSessions` walks descendants of the live tmux pane PID and is
|
|
225
|
+
* sufficient for sessions whose tmux container is still up. This handles the
|
|
226
|
+
* leftover case: a stored pid that is still alive but its tmux session is
|
|
227
|
+
* gone (claude was reparented to init when its bash wrapper got SIGHUP) or
|
|
228
|
+
* the session is in a terminal state but the spawn never exited. Best-effort.
|
|
229
|
+
* (overstory-505d)
|
|
230
|
+
*/
|
|
231
|
+
async function reapOrphanedPids(overstoryDir: string): Promise<number> {
|
|
232
|
+
let reaped = 0;
|
|
233
|
+
try {
|
|
234
|
+
const dbPath = join(overstoryDir, "sessions.db");
|
|
235
|
+
const jsonPath = join(overstoryDir, "sessions.json");
|
|
236
|
+
if (!existsSync(dbPath) && !existsSync(jsonPath)) {
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
const { store } = openSessionStore(overstoryDir);
|
|
240
|
+
try {
|
|
241
|
+
for (const session of store.getAll()) {
|
|
242
|
+
if (session.pid === null) continue;
|
|
243
|
+
if (!isProcessAlive(session.pid)) continue;
|
|
244
|
+
try {
|
|
245
|
+
await killProcessTree(session.pid);
|
|
246
|
+
reaped++;
|
|
247
|
+
} catch {
|
|
248
|
+
// Best effort
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
store.close();
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// Best effort
|
|
256
|
+
}
|
|
257
|
+
return reaped;
|
|
258
|
+
}
|
|
259
|
+
|
|
220
260
|
/**
|
|
221
261
|
* Remove all overstory worktrees (force remove with branch deletion).
|
|
222
262
|
*/
|
|
@@ -568,6 +608,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
568
608
|
const result: CleanResult = {
|
|
569
609
|
sessionEndEventsLogged: 0,
|
|
570
610
|
tmuxKilled: 0,
|
|
611
|
+
orphanPidsReaped: 0,
|
|
571
612
|
worktreesCleaned: 0,
|
|
572
613
|
branchesDeleted: 0,
|
|
573
614
|
mailWiped: false,
|
|
@@ -609,6 +650,14 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
609
650
|
result.tmuxKilled = await killAllTmuxSessions(overstoryDir, config.project.name);
|
|
610
651
|
}
|
|
611
652
|
|
|
653
|
+
// 2b. Reap any orphaned spawn PIDs that survived tmux teardown.
|
|
654
|
+
// Must run after killAllTmuxSessions (which collects descendants of live
|
|
655
|
+
// panes) but before sessions.db is wiped (we need pid records to find
|
|
656
|
+
// orphans). (overstory-505d)
|
|
657
|
+
if (doWorktrees || all) {
|
|
658
|
+
result.orphanPidsReaped = await reapOrphanedPids(overstoryDir);
|
|
659
|
+
}
|
|
660
|
+
|
|
612
661
|
// 3. Remove worktrees
|
|
613
662
|
if (doWorktrees) {
|
|
614
663
|
result.worktreesCleaned = await cleanAllWorktrees(root);
|
|
@@ -670,6 +719,11 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
|
|
|
670
719
|
if (result.tmuxKilled > 0) {
|
|
671
720
|
lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
|
|
672
721
|
}
|
|
722
|
+
if (result.orphanPidsReaped > 0) {
|
|
723
|
+
lines.push(
|
|
724
|
+
`Reaped ${result.orphanPidsReaped} orphaned spawn process${result.orphanPidsReaped === 1 ? "" : "es"}`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
673
727
|
if (result.worktreesCleaned > 0) {
|
|
674
728
|
lines.push(
|
|
675
729
|
`Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
coordinatorCommand,
|
|
29
29
|
createCoordinatorCommand,
|
|
30
30
|
resolveAttach,
|
|
31
|
+
startCoordinatorSession,
|
|
31
32
|
} from "./coordinator.ts";
|
|
32
33
|
import {
|
|
33
34
|
buildOrchestratorBeacon,
|
|
@@ -1600,6 +1601,133 @@ describe("watchdog integration", () => {
|
|
|
1600
1601
|
expect(output).toContain("--watchdog");
|
|
1601
1602
|
expect(output).toContain("watchdog");
|
|
1602
1603
|
});
|
|
1604
|
+
|
|
1605
|
+
test("start help text includes --accept-existing-watchdog flag", async () => {
|
|
1606
|
+
const cmd = createCoordinatorCommand({});
|
|
1607
|
+
for (const sub of cmd.commands) {
|
|
1608
|
+
sub.exitOverride();
|
|
1609
|
+
}
|
|
1610
|
+
const output = await captureStdout(async () => {
|
|
1611
|
+
await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
|
|
1612
|
+
});
|
|
1613
|
+
expect(output).toContain("--accept-existing-watchdog");
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// overstory-3f0c: detect leftover watchdog from a previous session before
|
|
1618
|
+
// spawning, so operators do not get unexpected watchdog supervision.
|
|
1619
|
+
describe("orphan watchdog detection (overstory-3f0c)", () => {
|
|
1620
|
+
// (a) start (no --watchdog) + isRunning=true -> throws AgentError with PID
|
|
1621
|
+
// and mention of --accept-existing-watchdog in the message
|
|
1622
|
+
test("rejects start with AgentError when no flag passed and watchdog already running", async () => {
|
|
1623
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
|
|
1624
|
+
const originalSleep = Bun.sleep;
|
|
1625
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1626
|
+
|
|
1627
|
+
try {
|
|
1628
|
+
await coordinatorCommand(["start", "--json"], deps);
|
|
1629
|
+
expect.unreachable("should have thrown AgentError");
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
1632
|
+
const ae = err as AgentError;
|
|
1633
|
+
expect(ae.message).toContain("Watchdog daemon");
|
|
1634
|
+
// PID is unavailable from the fake watchdog (no PID file written),
|
|
1635
|
+
// so the message reports "unknown PID" — but it must reference the
|
|
1636
|
+
// concept and the suppress flag explicitly.
|
|
1637
|
+
expect(ae.message).toMatch(/PID/);
|
|
1638
|
+
expect(ae.message).toContain("--accept-existing-watchdog");
|
|
1639
|
+
expect(ae.message).toContain("--watchdog");
|
|
1640
|
+
expect(ae.message).toContain("ov watch --kill-others");
|
|
1641
|
+
} finally {
|
|
1642
|
+
Bun.sleep = originalSleep;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// Detection ran but auto-start did NOT — the throw fired first.
|
|
1646
|
+
expect(watchdogCalls?.isRunning).toBeGreaterThanOrEqual(1);
|
|
1647
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
// (b) start --watchdog + isRunning=true -> does NOT throw;
|
|
1651
|
+
// watchdog.start() is still called once
|
|
1652
|
+
test("--watchdog with already-running daemon does NOT throw and still calls start()", async () => {
|
|
1653
|
+
const { deps, watchdogCalls } = makeDeps(
|
|
1654
|
+
{},
|
|
1655
|
+
{ running: true, startSuccess: false }, // startSuccess:false simulates the no-op-when-already-running return
|
|
1656
|
+
);
|
|
1657
|
+
const originalSleep = Bun.sleep;
|
|
1658
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1659
|
+
|
|
1660
|
+
let output: string;
|
|
1661
|
+
try {
|
|
1662
|
+
output = await captureStdout(() =>
|
|
1663
|
+
coordinatorCommand(["start", "--watchdog", "--json"], deps),
|
|
1664
|
+
);
|
|
1665
|
+
} finally {
|
|
1666
|
+
Bun.sleep = originalSleep;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
expect(watchdogCalls?.start).toBe(1);
|
|
1670
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1671
|
+
// reused-daemon sentinel keeps watchdog truthy in the JSON output
|
|
1672
|
+
expect(parsed.watchdog).toBe(true);
|
|
1673
|
+
expect(parsed.watchdogPreexisting).toBe(true);
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
// (c) start --accept-existing-watchdog + isRunning=true -> does NOT throw;
|
|
1677
|
+
// coordinator starts normally; watchdog.start() is NOT called
|
|
1678
|
+
test("--accept-existing-watchdog allows start without calling watchdog.start()", async () => {
|
|
1679
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
|
|
1680
|
+
const originalSleep = Bun.sleep;
|
|
1681
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1682
|
+
|
|
1683
|
+
let output: string;
|
|
1684
|
+
try {
|
|
1685
|
+
output = await captureStdout(() =>
|
|
1686
|
+
coordinatorCommand(["start", "--accept-existing-watchdog", "--json"], deps),
|
|
1687
|
+
);
|
|
1688
|
+
} finally {
|
|
1689
|
+
Bun.sleep = originalSleep;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1693
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1694
|
+
expect(parsed.watchdog).toBe(true);
|
|
1695
|
+
expect(parsed.watchdogPreexisting).toBe(true);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
// (d) start (no --watchdog) + isRunning=false -> no error, no start
|
|
1699
|
+
// (regression — preserves the original "no flag, no daemon activity" path)
|
|
1700
|
+
test("no flag + watchdog not running: starts normally without calling start()", async () => {
|
|
1701
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: false, startSuccess: true });
|
|
1702
|
+
const originalSleep = Bun.sleep;
|
|
1703
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1704
|
+
|
|
1705
|
+
let output: string;
|
|
1706
|
+
try {
|
|
1707
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1708
|
+
} finally {
|
|
1709
|
+
Bun.sleep = originalSleep;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1713
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1714
|
+
expect(parsed.watchdog).toBe(false);
|
|
1715
|
+
expect(parsed.watchdogPreexisting).toBe(false);
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
test("orchestrator inherits the same orphan-watchdog detection", async () => {
|
|
1719
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: true });
|
|
1720
|
+
const originalSleep = Bun.sleep;
|
|
1721
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1722
|
+
|
|
1723
|
+
try {
|
|
1724
|
+
await expect(orchestratorCommand(["start", "--json"], deps)).rejects.toThrow(AgentError);
|
|
1725
|
+
} finally {
|
|
1726
|
+
Bun.sleep = originalSleep;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1730
|
+
});
|
|
1603
1731
|
});
|
|
1604
1732
|
});
|
|
1605
1733
|
|
|
@@ -2665,3 +2793,129 @@ describe("checkComplete", () => {
|
|
|
2665
2793
|
expect(subcommandNames).toContain("check-complete");
|
|
2666
2794
|
});
|
|
2667
2795
|
});
|
|
2796
|
+
|
|
2797
|
+
describe("startCoordinatorSession headless", () => {
|
|
2798
|
+
test("with headless: true, calls spawnHeadlessAgent and skips tmux", async () => {
|
|
2799
|
+
const { tmux, calls: tmuxCalls } = makeFakeTmux();
|
|
2800
|
+
const { watchdog } = makeFakeWatchdog();
|
|
2801
|
+
const { monitor } = makeFakeMonitor();
|
|
2802
|
+
|
|
2803
|
+
const spawnCalls: Array<{
|
|
2804
|
+
argv: string[];
|
|
2805
|
+
cwd: string;
|
|
2806
|
+
agentName?: string;
|
|
2807
|
+
}> = [];
|
|
2808
|
+
const writes: string[] = [];
|
|
2809
|
+
|
|
2810
|
+
const fakeSpawn = async (
|
|
2811
|
+
argv: string[],
|
|
2812
|
+
opts: { cwd: string; env: Record<string, string>; agentName?: string },
|
|
2813
|
+
): Promise<{
|
|
2814
|
+
pid: number;
|
|
2815
|
+
stdin: { write(data: string | Uint8Array): number | Promise<number> };
|
|
2816
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
2817
|
+
}> => {
|
|
2818
|
+
spawnCalls.push({ argv, cwd: opts.cwd, agentName: opts.agentName });
|
|
2819
|
+
return {
|
|
2820
|
+
pid: 55555,
|
|
2821
|
+
stdin: {
|
|
2822
|
+
write(data: string | Uint8Array): number {
|
|
2823
|
+
writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
2824
|
+
return 0;
|
|
2825
|
+
},
|
|
2826
|
+
},
|
|
2827
|
+
stdout: null,
|
|
2828
|
+
};
|
|
2829
|
+
};
|
|
2830
|
+
|
|
2831
|
+
const deps: CoordinatorDeps = {
|
|
2832
|
+
_tmux: tmux,
|
|
2833
|
+
_watchdog: watchdog,
|
|
2834
|
+
_monitor: monitor,
|
|
2835
|
+
_spawnHeadless: fakeSpawn,
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
await captureStdout(async () => {
|
|
2839
|
+
await startCoordinatorSession(
|
|
2840
|
+
{
|
|
2841
|
+
json: true,
|
|
2842
|
+
attach: false,
|
|
2843
|
+
watchdog: false,
|
|
2844
|
+
monitor: false,
|
|
2845
|
+
headless: true,
|
|
2846
|
+
},
|
|
2847
|
+
deps,
|
|
2848
|
+
);
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
// spawnHeadlessAgent was called exactly once with agentName: "coordinator"
|
|
2852
|
+
expect(spawnCalls.length).toBe(1);
|
|
2853
|
+
expect(spawnCalls[0]?.agentName).toBe("coordinator");
|
|
2854
|
+
expect(spawnCalls[0]?.cwd).toBe(tempDir);
|
|
2855
|
+
|
|
2856
|
+
// initial stdin prompt was written
|
|
2857
|
+
expect(writes.length).toBeGreaterThanOrEqual(1);
|
|
2858
|
+
|
|
2859
|
+
// tmux helpers were never called for the headless path
|
|
2860
|
+
expect(tmuxCalls.createSession.length).toBe(0);
|
|
2861
|
+
expect(tmuxCalls.sendKeys.length).toBe(0);
|
|
2862
|
+
expect(tmuxCalls.waitForTuiReady.length).toBe(0);
|
|
2863
|
+
expect(tmuxCalls.ensureTmuxAvailable).toBe(0);
|
|
2864
|
+
|
|
2865
|
+
// Session row records empty tmuxSession + the headless spawn pid
|
|
2866
|
+
const sessions = loadSessionsFromDb();
|
|
2867
|
+
expect(sessions.length).toBe(1);
|
|
2868
|
+
expect(sessions[0]?.agentName).toBe("coordinator");
|
|
2869
|
+
expect(sessions[0]?.tmuxSession).toBe("");
|
|
2870
|
+
expect(sessions[0]?.pid).toBe(55555);
|
|
2871
|
+
expect(sessions[0]?.state).toBe("booting");
|
|
2872
|
+
|
|
2873
|
+
// current-run.txt was written for downstream consumers
|
|
2874
|
+
const runFile = Bun.file(join(overstoryDir, "current-run.txt"));
|
|
2875
|
+
expect(await runFile.exists()).toBe(true);
|
|
2876
|
+
});
|
|
2877
|
+
|
|
2878
|
+
test("rejects when runtime has no buildDirectSpawn", async () => {
|
|
2879
|
+
// Override config to route the coordinator capability to a runtime that
|
|
2880
|
+
// lacks buildDirectSpawn (e.g. cursor). The headless path must reject.
|
|
2881
|
+
await Bun.write(
|
|
2882
|
+
join(overstoryDir, "config.yaml"),
|
|
2883
|
+
[
|
|
2884
|
+
"project:",
|
|
2885
|
+
" name: test-project",
|
|
2886
|
+
` root: ${tempDir}`,
|
|
2887
|
+
" canonicalBranch: main",
|
|
2888
|
+
"watchdog:",
|
|
2889
|
+
" tier2Enabled: true",
|
|
2890
|
+
"runtime:",
|
|
2891
|
+
" capabilities:",
|
|
2892
|
+
" coordinator: cursor",
|
|
2893
|
+
].join("\n"),
|
|
2894
|
+
);
|
|
2895
|
+
|
|
2896
|
+
const { tmux } = makeFakeTmux();
|
|
2897
|
+
const { watchdog } = makeFakeWatchdog();
|
|
2898
|
+
const { monitor } = makeFakeMonitor();
|
|
2899
|
+
const deps: CoordinatorDeps = {
|
|
2900
|
+
_tmux: tmux,
|
|
2901
|
+
_watchdog: watchdog,
|
|
2902
|
+
_monitor: monitor,
|
|
2903
|
+
_spawnHeadless: async () => {
|
|
2904
|
+
throw new Error("should not be called");
|
|
2905
|
+
},
|
|
2906
|
+
};
|
|
2907
|
+
|
|
2908
|
+
await expect(
|
|
2909
|
+
startCoordinatorSession(
|
|
2910
|
+
{
|
|
2911
|
+
json: true,
|
|
2912
|
+
attach: false,
|
|
2913
|
+
watchdog: false,
|
|
2914
|
+
monitor: false,
|
|
2915
|
+
headless: true,
|
|
2916
|
+
},
|
|
2917
|
+
deps,
|
|
2918
|
+
),
|
|
2919
|
+
).rejects.toThrow(ValidationError);
|
|
2920
|
+
});
|
|
2921
|
+
});
|