@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,1530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for overstory coordinator command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories and real git repos for file I/O and config loading.
|
|
5
|
+
* Tmux is injected via the CoordinatorDeps DI interface instead of
|
|
6
|
+
* mock.module() to avoid the process-global mock leak issue
|
|
7
|
+
* (see mulch record mx-56558b).
|
|
8
|
+
*
|
|
9
|
+
* WHY DI instead of mock.module: mock.module() in bun:test is process-global
|
|
10
|
+
* and leaks across test files. The DI approach (same pattern as daemon.ts
|
|
11
|
+
* _tmux/_triage/_nudge) ensures mocks are scoped to each test invocation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdir, realpath } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
19
|
+
import { createRunStore } from "../sessions/store.ts";
|
|
20
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
21
|
+
import type { AgentSession } from "../types.ts";
|
|
22
|
+
import {
|
|
23
|
+
buildCoordinatorBeacon,
|
|
24
|
+
type CoordinatorDeps,
|
|
25
|
+
coordinatorCommand,
|
|
26
|
+
resolveAttach,
|
|
27
|
+
} from "./coordinator.ts";
|
|
28
|
+
import { isRunningAsRoot } from "./sling.ts";
|
|
29
|
+
|
|
30
|
+
// --- Fake Tmux ---
|
|
31
|
+
|
|
32
|
+
/** Track calls to fake tmux for assertions. */
|
|
33
|
+
interface TmuxCallTracker {
|
|
34
|
+
createSession: Array<{
|
|
35
|
+
name: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
command: string;
|
|
38
|
+
env?: Record<string, string>;
|
|
39
|
+
}>;
|
|
40
|
+
isSessionAlive: Array<{ name: string; result: boolean }>;
|
|
41
|
+
killSession: Array<{ name: string }>;
|
|
42
|
+
sendKeys: Array<{ name: string; keys: string }>;
|
|
43
|
+
waitForTuiReady: Array<{ name: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Fake Watchdog ---
|
|
47
|
+
|
|
48
|
+
/** Track calls to fake watchdog for assertions. */
|
|
49
|
+
interface WatchdogCallTracker {
|
|
50
|
+
start: number;
|
|
51
|
+
stop: number;
|
|
52
|
+
isRunning: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Fake Monitor ---
|
|
56
|
+
|
|
57
|
+
/** Track calls to fake monitor for assertions. */
|
|
58
|
+
interface MonitorCallTracker {
|
|
59
|
+
start: number;
|
|
60
|
+
stop: number;
|
|
61
|
+
isRunning: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build a fake tmux DI object with configurable session liveness. */
|
|
65
|
+
function makeFakeTmux(sessionAliveMap: Record<string, boolean> = {}): {
|
|
66
|
+
tmux: NonNullable<CoordinatorDeps["_tmux"]>;
|
|
67
|
+
calls: TmuxCallTracker;
|
|
68
|
+
} {
|
|
69
|
+
const calls: TmuxCallTracker = {
|
|
70
|
+
createSession: [],
|
|
71
|
+
isSessionAlive: [],
|
|
72
|
+
killSession: [],
|
|
73
|
+
sendKeys: [],
|
|
74
|
+
waitForTuiReady: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const tmux: NonNullable<CoordinatorDeps["_tmux"]> = {
|
|
78
|
+
createSession: async (
|
|
79
|
+
name: string,
|
|
80
|
+
cwd: string,
|
|
81
|
+
command: string,
|
|
82
|
+
env?: Record<string, string>,
|
|
83
|
+
): Promise<number> => {
|
|
84
|
+
calls.createSession.push({ name, cwd, command, env });
|
|
85
|
+
return 99999; // Fake PID
|
|
86
|
+
},
|
|
87
|
+
isSessionAlive: async (name: string): Promise<boolean> => {
|
|
88
|
+
const alive = sessionAliveMap[name] ?? false;
|
|
89
|
+
calls.isSessionAlive.push({ name, result: alive });
|
|
90
|
+
return alive;
|
|
91
|
+
},
|
|
92
|
+
killSession: async (name: string): Promise<void> => {
|
|
93
|
+
calls.killSession.push({ name });
|
|
94
|
+
},
|
|
95
|
+
sendKeys: async (name: string, keys: string): Promise<void> => {
|
|
96
|
+
calls.sendKeys.push({ name, keys });
|
|
97
|
+
},
|
|
98
|
+
waitForTuiReady: async (name: string): Promise<boolean> => {
|
|
99
|
+
calls.waitForTuiReady.push({ name });
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return { tmux, calls };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a fake watchdog DI object with configurable behavior.
|
|
109
|
+
* @param running - Whether the watchdog should report as running
|
|
110
|
+
* @param startSuccess - Whether start() should succeed (return a PID)
|
|
111
|
+
* @param stopSuccess - Whether stop() should succeed (return true)
|
|
112
|
+
*/
|
|
113
|
+
function makeFakeWatchdog(
|
|
114
|
+
running = false,
|
|
115
|
+
startSuccess = true,
|
|
116
|
+
stopSuccess = true,
|
|
117
|
+
): {
|
|
118
|
+
watchdog: NonNullable<CoordinatorDeps["_watchdog"]>;
|
|
119
|
+
calls: WatchdogCallTracker;
|
|
120
|
+
} {
|
|
121
|
+
const calls: WatchdogCallTracker = {
|
|
122
|
+
start: 0,
|
|
123
|
+
stop: 0,
|
|
124
|
+
isRunning: 0,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const watchdog: NonNullable<CoordinatorDeps["_watchdog"]> = {
|
|
128
|
+
async start(): Promise<{ pid: number } | null> {
|
|
129
|
+
calls.start++;
|
|
130
|
+
return startSuccess ? { pid: 88888 } : null;
|
|
131
|
+
},
|
|
132
|
+
async stop(): Promise<boolean> {
|
|
133
|
+
calls.stop++;
|
|
134
|
+
return stopSuccess;
|
|
135
|
+
},
|
|
136
|
+
async isRunning(): Promise<boolean> {
|
|
137
|
+
calls.isRunning++;
|
|
138
|
+
return running;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { watchdog, calls };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a fake monitor DI object with configurable behavior.
|
|
147
|
+
* @param running - Whether the monitor should report as running
|
|
148
|
+
* @param startSuccess - Whether start() should succeed (return a PID)
|
|
149
|
+
* @param stopSuccess - Whether stop() should succeed (return true)
|
|
150
|
+
*/
|
|
151
|
+
function makeFakeMonitor(
|
|
152
|
+
running = false,
|
|
153
|
+
startSuccess = true,
|
|
154
|
+
stopSuccess = true,
|
|
155
|
+
): {
|
|
156
|
+
monitor: NonNullable<CoordinatorDeps["_monitor"]>;
|
|
157
|
+
calls: MonitorCallTracker;
|
|
158
|
+
} {
|
|
159
|
+
const calls: MonitorCallTracker = {
|
|
160
|
+
start: 0,
|
|
161
|
+
stop: 0,
|
|
162
|
+
isRunning: 0,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const monitor: NonNullable<CoordinatorDeps["_monitor"]> = {
|
|
166
|
+
async start(): Promise<{ pid: number } | null> {
|
|
167
|
+
calls.start++;
|
|
168
|
+
return startSuccess ? { pid: 77777 } : null;
|
|
169
|
+
},
|
|
170
|
+
async stop(): Promise<boolean> {
|
|
171
|
+
calls.stop++;
|
|
172
|
+
return stopSuccess;
|
|
173
|
+
},
|
|
174
|
+
async isRunning(): Promise<boolean> {
|
|
175
|
+
calls.isRunning++;
|
|
176
|
+
return running;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return { monitor, calls };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Test Setup ---
|
|
184
|
+
|
|
185
|
+
let tempDir: string;
|
|
186
|
+
let overstoryDir: string;
|
|
187
|
+
const originalCwd = process.cwd();
|
|
188
|
+
|
|
189
|
+
/** Save sessions to the SessionStore (sessions.db) for test setup. */
|
|
190
|
+
function saveSessionsToDb(sessions: AgentSession[]): void {
|
|
191
|
+
const { store } = openSessionStore(overstoryDir);
|
|
192
|
+
try {
|
|
193
|
+
for (const session of sessions) {
|
|
194
|
+
store.upsert(session);
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
store.close();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Load all sessions from the SessionStore (sessions.db). */
|
|
202
|
+
function loadSessionsFromDb(): AgentSession[] {
|
|
203
|
+
const { store } = openSessionStore(overstoryDir);
|
|
204
|
+
try {
|
|
205
|
+
return store.getAll();
|
|
206
|
+
} finally {
|
|
207
|
+
store.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
beforeEach(async () => {
|
|
212
|
+
// Restore cwd FIRST so createTempGitRepo's git operations don't fail
|
|
213
|
+
// if a prior test's tempDir was already cleaned up.
|
|
214
|
+
process.chdir(originalCwd);
|
|
215
|
+
|
|
216
|
+
tempDir = await realpath(await createTempGitRepo());
|
|
217
|
+
overstoryDir = join(tempDir, ".overstory");
|
|
218
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
219
|
+
|
|
220
|
+
// Write a minimal config.yaml so loadConfig succeeds
|
|
221
|
+
// tier2Enabled: true so existing --monitor tests pass (new skipped tests override inline)
|
|
222
|
+
await Bun.write(
|
|
223
|
+
join(overstoryDir, "config.yaml"),
|
|
224
|
+
[
|
|
225
|
+
"project:",
|
|
226
|
+
" name: test-project",
|
|
227
|
+
` root: ${tempDir}`,
|
|
228
|
+
" canonicalBranch: main",
|
|
229
|
+
"watchdog:",
|
|
230
|
+
" tier2Enabled: true",
|
|
231
|
+
].join("\n"),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Write agent-manifest.json and stub agent-def .md files so manifest loading succeeds
|
|
235
|
+
const agentDefsDir = join(overstoryDir, "agent-defs");
|
|
236
|
+
await mkdir(agentDefsDir, { recursive: true });
|
|
237
|
+
const manifest = {
|
|
238
|
+
version: "1.0",
|
|
239
|
+
agents: {
|
|
240
|
+
coordinator: {
|
|
241
|
+
file: "coordinator.md",
|
|
242
|
+
model: "opus",
|
|
243
|
+
tools: ["Read", "Bash"],
|
|
244
|
+
capabilities: ["coordinate"],
|
|
245
|
+
canSpawn: true,
|
|
246
|
+
constraints: [],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
capabilityIndex: { coordinate: ["coordinator"] },
|
|
250
|
+
};
|
|
251
|
+
await Bun.write(
|
|
252
|
+
join(overstoryDir, "agent-manifest.json"),
|
|
253
|
+
`${JSON.stringify(manifest, null, "\t")}\n`,
|
|
254
|
+
);
|
|
255
|
+
await Bun.write(join(agentDefsDir, "coordinator.md"), "# Coordinator\n");
|
|
256
|
+
|
|
257
|
+
// Override cwd so coordinator commands find our temp project
|
|
258
|
+
process.chdir(tempDir);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
afterEach(async () => {
|
|
262
|
+
process.chdir(originalCwd);
|
|
263
|
+
await cleanupTempDir(tempDir);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// --- Helpers ---
|
|
267
|
+
|
|
268
|
+
function makeCoordinatorSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
269
|
+
return {
|
|
270
|
+
id: `session-${Date.now()}-coordinator`,
|
|
271
|
+
agentName: "coordinator",
|
|
272
|
+
capability: "coordinator",
|
|
273
|
+
worktreePath: tempDir,
|
|
274
|
+
branchName: "main",
|
|
275
|
+
beadId: "",
|
|
276
|
+
tmuxSession: "overstory-test-project-coordinator",
|
|
277
|
+
state: "working",
|
|
278
|
+
pid: 99999,
|
|
279
|
+
parentAgent: null,
|
|
280
|
+
depth: 0,
|
|
281
|
+
runId: null,
|
|
282
|
+
startedAt: new Date().toISOString(),
|
|
283
|
+
lastActivity: new Date().toISOString(),
|
|
284
|
+
escalationLevel: 0,
|
|
285
|
+
stalledSince: null,
|
|
286
|
+
...overrides,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Capture stdout.write output during a function call. */
|
|
291
|
+
async function captureStdout(fn: () => Promise<void>): Promise<string> {
|
|
292
|
+
const chunks: string[] = [];
|
|
293
|
+
const originalWrite = process.stdout.write;
|
|
294
|
+
process.stdout.write = ((chunk: string) => {
|
|
295
|
+
chunks.push(chunk);
|
|
296
|
+
return true;
|
|
297
|
+
}) as typeof process.stdout.write;
|
|
298
|
+
try {
|
|
299
|
+
await fn();
|
|
300
|
+
} finally {
|
|
301
|
+
process.stdout.write = originalWrite;
|
|
302
|
+
}
|
|
303
|
+
return chunks.join("");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Build default CoordinatorDeps with fake tmux, watchdog, and monitor.
|
|
307
|
+
* Always injects fakes for all three to prevent real Bun.spawn(["overstory", ...])
|
|
308
|
+
* calls in tests (overstory CLI is not available in CI). */
|
|
309
|
+
function makeDeps(
|
|
310
|
+
sessionAliveMap: Record<string, boolean> = {},
|
|
311
|
+
watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
|
|
312
|
+
monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
|
|
313
|
+
): {
|
|
314
|
+
deps: CoordinatorDeps;
|
|
315
|
+
calls: TmuxCallTracker;
|
|
316
|
+
watchdogCalls: WatchdogCallTracker;
|
|
317
|
+
monitorCalls: MonitorCallTracker;
|
|
318
|
+
} {
|
|
319
|
+
const { tmux, calls } = makeFakeTmux(sessionAliveMap);
|
|
320
|
+
const { watchdog, calls: watchdogCalls } = makeFakeWatchdog(
|
|
321
|
+
watchdogConfig?.running,
|
|
322
|
+
watchdogConfig?.startSuccess,
|
|
323
|
+
watchdogConfig?.stopSuccess,
|
|
324
|
+
);
|
|
325
|
+
const { monitor, calls: monitorCalls } = makeFakeMonitor(
|
|
326
|
+
monitorConfig?.running,
|
|
327
|
+
monitorConfig?.startSuccess,
|
|
328
|
+
monitorConfig?.stopSuccess,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const deps: CoordinatorDeps = {
|
|
332
|
+
_tmux: tmux,
|
|
333
|
+
_watchdog: watchdog,
|
|
334
|
+
_monitor: monitor,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
deps,
|
|
339
|
+
calls,
|
|
340
|
+
watchdogCalls,
|
|
341
|
+
monitorCalls,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Tests ---
|
|
346
|
+
|
|
347
|
+
describe("coordinatorCommand help", () => {
|
|
348
|
+
test("--help outputs help text", async () => {
|
|
349
|
+
const output = await captureStdout(() => coordinatorCommand(["--help"]));
|
|
350
|
+
expect(output).toContain("overstory coordinator");
|
|
351
|
+
expect(output).toContain("start");
|
|
352
|
+
expect(output).toContain("stop");
|
|
353
|
+
expect(output).toContain("status");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("--help includes --attach and --no-attach flags", async () => {
|
|
357
|
+
const output = await captureStdout(() => coordinatorCommand(["--help"]));
|
|
358
|
+
expect(output).toContain("--attach");
|
|
359
|
+
expect(output).toContain("--no-attach");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("-h outputs help text", async () => {
|
|
363
|
+
const output = await captureStdout(() => coordinatorCommand(["-h"]));
|
|
364
|
+
expect(output).toContain("overstory coordinator");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("empty args outputs help text", async () => {
|
|
368
|
+
const output = await captureStdout(() => coordinatorCommand([]));
|
|
369
|
+
expect(output).toContain("overstory coordinator");
|
|
370
|
+
expect(output).toContain("Subcommands:");
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("coordinatorCommand unknown subcommand", () => {
|
|
375
|
+
test("throws ValidationError for unknown subcommand", async () => {
|
|
376
|
+
await expect(coordinatorCommand(["frobnicate"])).rejects.toThrow(ValidationError);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("error message includes the bad subcommand name", async () => {
|
|
380
|
+
try {
|
|
381
|
+
await coordinatorCommand(["frobnicate"]);
|
|
382
|
+
expect.unreachable("should have thrown");
|
|
383
|
+
} catch (err) {
|
|
384
|
+
expect(err).toBeInstanceOf(ValidationError);
|
|
385
|
+
const ve = err as ValidationError;
|
|
386
|
+
expect(ve.message).toContain("frobnicate");
|
|
387
|
+
expect(ve.field).toBe("subcommand");
|
|
388
|
+
expect(ve.value).toBe("frobnicate");
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe("startCoordinator", () => {
|
|
394
|
+
test("writes session to sessions.json with correct fields", async () => {
|
|
395
|
+
const { deps, calls } = makeDeps();
|
|
396
|
+
|
|
397
|
+
// Override Bun.sleep to skip the 3s and 0.5s waits
|
|
398
|
+
const originalSleep = Bun.sleep;
|
|
399
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
403
|
+
} finally {
|
|
404
|
+
Bun.sleep = originalSleep;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Verify sessions.json was written
|
|
408
|
+
const sessions = loadSessionsFromDb();
|
|
409
|
+
expect(sessions).toHaveLength(1);
|
|
410
|
+
|
|
411
|
+
const session = sessions[0];
|
|
412
|
+
expect(session).toBeDefined();
|
|
413
|
+
expect(session?.agentName).toBe("coordinator");
|
|
414
|
+
expect(session?.capability).toBe("coordinator");
|
|
415
|
+
expect(session?.tmuxSession).toBe("overstory-test-project-coordinator");
|
|
416
|
+
expect(session?.state).toBe("booting");
|
|
417
|
+
expect(session?.pid).toBe(99999);
|
|
418
|
+
expect(session?.parentAgent).toBeNull();
|
|
419
|
+
expect(session?.depth).toBe(0);
|
|
420
|
+
expect(session?.beadId).toBe("");
|
|
421
|
+
expect(session?.branchName).toBe("main");
|
|
422
|
+
expect(session?.worktreePath).toBe(tempDir);
|
|
423
|
+
expect(session?.id).toMatch(/^session-\d+-coordinator$/);
|
|
424
|
+
|
|
425
|
+
// Verify tmux createSession was called
|
|
426
|
+
expect(calls.createSession).toHaveLength(1);
|
|
427
|
+
expect(calls.createSession[0]?.name).toBe("overstory-test-project-coordinator");
|
|
428
|
+
expect(calls.createSession[0]?.cwd).toBe(tempDir);
|
|
429
|
+
|
|
430
|
+
// Verify sendKeys was called (beacon + follow-up Enter)
|
|
431
|
+
expect(calls.sendKeys.length).toBeGreaterThanOrEqual(1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("deploys hooks to project root .claude/settings.local.json", async () => {
|
|
435
|
+
const { deps } = makeDeps();
|
|
436
|
+
const originalSleep = Bun.sleep;
|
|
437
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
441
|
+
} finally {
|
|
442
|
+
Bun.sleep = originalSleep;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Verify .claude/settings.local.json was created at the project root
|
|
446
|
+
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
447
|
+
const settingsFile = Bun.file(settingsPath);
|
|
448
|
+
expect(await settingsFile.exists()).toBe(true);
|
|
449
|
+
|
|
450
|
+
const content = await settingsFile.text();
|
|
451
|
+
const config = JSON.parse(content) as {
|
|
452
|
+
hooks: Record<string, unknown[]>;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Verify hook categories exist
|
|
456
|
+
expect(config.hooks).toBeDefined();
|
|
457
|
+
expect(config.hooks.SessionStart).toBeDefined();
|
|
458
|
+
expect(config.hooks.UserPromptSubmit).toBeDefined();
|
|
459
|
+
expect(config.hooks.PreToolUse).toBeDefined();
|
|
460
|
+
expect(config.hooks.PostToolUse).toBeDefined();
|
|
461
|
+
expect(config.hooks.Stop).toBeDefined();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("hooks use coordinator agent name for event logging", async () => {
|
|
465
|
+
const { deps } = makeDeps();
|
|
466
|
+
const originalSleep = Bun.sleep;
|
|
467
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
471
|
+
} finally {
|
|
472
|
+
Bun.sleep = originalSleep;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
476
|
+
const content = await Bun.file(settingsPath).text();
|
|
477
|
+
|
|
478
|
+
// The hooks should reference the coordinator agent name
|
|
479
|
+
expect(content).toContain("--agent coordinator");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("hooks include ENV_GUARD to avoid affecting user's Claude Code session", async () => {
|
|
483
|
+
const { deps } = makeDeps();
|
|
484
|
+
const originalSleep = Bun.sleep;
|
|
485
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
489
|
+
} finally {
|
|
490
|
+
Bun.sleep = originalSleep;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
494
|
+
const content = await Bun.file(settingsPath).text();
|
|
495
|
+
|
|
496
|
+
// PreToolUse guards should include the ENV_GUARD prefix
|
|
497
|
+
expect(content).toContain("OVERSTORY_AGENT_NAME");
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("injects agent definition via --append-system-prompt when agent-defs/coordinator.md exists", async () => {
|
|
501
|
+
// Deploy a coordinator agent definition
|
|
502
|
+
const agentDefsDir = join(overstoryDir, "agent-defs");
|
|
503
|
+
await mkdir(agentDefsDir, { recursive: true });
|
|
504
|
+
await Bun.write(
|
|
505
|
+
join(agentDefsDir, "coordinator.md"),
|
|
506
|
+
"# Coordinator Agent\n\nYou are the coordinator.\n",
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const { deps, calls } = makeDeps();
|
|
510
|
+
const originalSleep = Bun.sleep;
|
|
511
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
|
|
515
|
+
} finally {
|
|
516
|
+
Bun.sleep = originalSleep;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
expect(calls.createSession).toHaveLength(1);
|
|
520
|
+
const cmd = calls.createSession[0]?.command ?? "";
|
|
521
|
+
expect(cmd).toContain("--append-system-prompt");
|
|
522
|
+
expect(cmd).toContain("# Coordinator Agent");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("reads model from manifest instead of hardcoding", async () => {
|
|
526
|
+
// Override the manifest to use sonnet instead of default opus
|
|
527
|
+
const manifest = {
|
|
528
|
+
version: "1.0",
|
|
529
|
+
agents: {
|
|
530
|
+
coordinator: {
|
|
531
|
+
file: "coordinator.md",
|
|
532
|
+
model: "sonnet",
|
|
533
|
+
tools: ["Read", "Bash"],
|
|
534
|
+
capabilities: ["coordinate"],
|
|
535
|
+
canSpawn: true,
|
|
536
|
+
constraints: [],
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
capabilityIndex: { coordinate: ["coordinator"] },
|
|
540
|
+
};
|
|
541
|
+
await Bun.write(
|
|
542
|
+
join(overstoryDir, "agent-manifest.json"),
|
|
543
|
+
`${JSON.stringify(manifest, null, "\t")}\n`,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const { deps, calls } = makeDeps();
|
|
547
|
+
const originalSleep = Bun.sleep;
|
|
548
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
|
|
552
|
+
} finally {
|
|
553
|
+
Bun.sleep = originalSleep;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
expect(calls.createSession).toHaveLength(1);
|
|
557
|
+
const cmd = calls.createSession[0]?.command ?? "";
|
|
558
|
+
expect(cmd).toContain("--model sonnet");
|
|
559
|
+
expect(cmd).not.toContain("--model opus");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("--json outputs JSON with expected fields", async () => {
|
|
563
|
+
const { deps } = makeDeps();
|
|
564
|
+
const originalSleep = Bun.sleep;
|
|
565
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
566
|
+
|
|
567
|
+
let output: string;
|
|
568
|
+
try {
|
|
569
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
570
|
+
} finally {
|
|
571
|
+
Bun.sleep = originalSleep;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
575
|
+
expect(parsed.agentName).toBe("coordinator");
|
|
576
|
+
expect(parsed.capability).toBe("coordinator");
|
|
577
|
+
expect(parsed.tmuxSession).toBe("overstory-test-project-coordinator");
|
|
578
|
+
expect(parsed.pid).toBe(99999);
|
|
579
|
+
expect(parsed.projectRoot).toBe(tempDir);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("rejects duplicate when coordinator is already running", async () => {
|
|
583
|
+
// Write an existing active coordinator session
|
|
584
|
+
const existing = makeCoordinatorSession({ state: "working" });
|
|
585
|
+
saveSessionsToDb([existing]);
|
|
586
|
+
|
|
587
|
+
// Mock tmux as alive for the existing session
|
|
588
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
589
|
+
|
|
590
|
+
await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
await coordinatorCommand(["start"], deps);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
596
|
+
const ae = err as AgentError;
|
|
597
|
+
expect(ae.message).toContain("already running");
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("cleans up dead session and starts new one", async () => {
|
|
602
|
+
// Write an existing session that claims to be working
|
|
603
|
+
const deadSession = makeCoordinatorSession({
|
|
604
|
+
id: "session-dead-coordinator",
|
|
605
|
+
state: "working",
|
|
606
|
+
});
|
|
607
|
+
saveSessionsToDb([deadSession]);
|
|
608
|
+
|
|
609
|
+
// Mock tmux as NOT alive for the existing session
|
|
610
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
611
|
+
|
|
612
|
+
const originalSleep = Bun.sleep;
|
|
613
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
617
|
+
} finally {
|
|
618
|
+
Bun.sleep = originalSleep;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// SessionStore uses UNIQUE(agent_name), so the new session replaces the old one.
|
|
622
|
+
// Verify the new session is in booting state with the coordinator name.
|
|
623
|
+
const sessions = loadSessionsFromDb();
|
|
624
|
+
expect(sessions).toHaveLength(1);
|
|
625
|
+
|
|
626
|
+
const newSession = sessions[0];
|
|
627
|
+
expect(newSession).toBeDefined();
|
|
628
|
+
expect(newSession?.state).toBe("booting");
|
|
629
|
+
expect(newSession?.agentName).toBe("coordinator");
|
|
630
|
+
// The new session should have a different ID than the dead one
|
|
631
|
+
expect(newSession?.id).not.toBe("session-dead-coordinator");
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
describe("stopCoordinator", () => {
|
|
636
|
+
test("marks session as completed after stopping", async () => {
|
|
637
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
638
|
+
saveSessionsToDb([session]);
|
|
639
|
+
|
|
640
|
+
// Tmux is alive so killSession will be called
|
|
641
|
+
const { deps, calls } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
642
|
+
|
|
643
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
644
|
+
|
|
645
|
+
// Verify session is now completed
|
|
646
|
+
const sessions = loadSessionsFromDb();
|
|
647
|
+
expect(sessions).toHaveLength(1);
|
|
648
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
649
|
+
|
|
650
|
+
// Verify killSession was called
|
|
651
|
+
expect(calls.killSession).toHaveLength(1);
|
|
652
|
+
expect(calls.killSession[0]?.name).toBe("overstory-test-project-coordinator");
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("--json outputs JSON with stopped flag", async () => {
|
|
656
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
657
|
+
saveSessionsToDb([session]);
|
|
658
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
659
|
+
|
|
660
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
661
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
662
|
+
expect(parsed.stopped).toBe(true);
|
|
663
|
+
expect(parsed.sessionId).toBe(session.id);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("handles already-dead tmux session gracefully", async () => {
|
|
667
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
668
|
+
saveSessionsToDb([session]);
|
|
669
|
+
|
|
670
|
+
// Tmux is NOT alive — should skip killSession
|
|
671
|
+
const { deps, calls } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
672
|
+
|
|
673
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
674
|
+
|
|
675
|
+
// Verify session is completed
|
|
676
|
+
const sessions = loadSessionsFromDb();
|
|
677
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
678
|
+
|
|
679
|
+
// killSession should NOT have been called since session was already dead
|
|
680
|
+
expect(calls.killSession).toHaveLength(0);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("throws AgentError when no coordinator session exists", async () => {
|
|
684
|
+
const { deps } = makeDeps();
|
|
685
|
+
|
|
686
|
+
// No sessions.json at all
|
|
687
|
+
await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
await coordinatorCommand(["stop"], deps);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
693
|
+
const ae = err as AgentError;
|
|
694
|
+
expect(ae.message).toContain("No active coordinator session");
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("throws AgentError when only completed sessions exist", async () => {
|
|
699
|
+
const completed = makeCoordinatorSession({ state: "completed" });
|
|
700
|
+
saveSessionsToDb([completed]);
|
|
701
|
+
const { deps } = makeDeps();
|
|
702
|
+
|
|
703
|
+
await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
describe("stopCoordinator run completion", () => {
|
|
708
|
+
test("coordinator stop auto-completes the active run", async () => {
|
|
709
|
+
// Create a coordinator session
|
|
710
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
711
|
+
saveSessionsToDb([session]);
|
|
712
|
+
|
|
713
|
+
// Create a run in RunStore
|
|
714
|
+
const dbPath = join(overstoryDir, "sessions.db");
|
|
715
|
+
const runStore = createRunStore(dbPath);
|
|
716
|
+
runStore.createRun({
|
|
717
|
+
id: "run-test-123",
|
|
718
|
+
startedAt: new Date().toISOString(),
|
|
719
|
+
coordinatorSessionId: null,
|
|
720
|
+
status: "active",
|
|
721
|
+
});
|
|
722
|
+
runStore.close();
|
|
723
|
+
|
|
724
|
+
// Write current-run.txt
|
|
725
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), "run-test-123");
|
|
726
|
+
|
|
727
|
+
// Stop coordinator
|
|
728
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
729
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
730
|
+
|
|
731
|
+
// Verify run status is "completed"
|
|
732
|
+
const runStoreCheck = createRunStore(dbPath);
|
|
733
|
+
const run = runStoreCheck.getRun("run-test-123");
|
|
734
|
+
runStoreCheck.close();
|
|
735
|
+
expect(run?.status).toBe("completed");
|
|
736
|
+
|
|
737
|
+
// Verify current-run.txt is deleted
|
|
738
|
+
const currentRunFile = Bun.file(join(overstoryDir, "current-run.txt"));
|
|
739
|
+
expect(await currentRunFile.exists()).toBe(false);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("coordinator stop succeeds when no active run exists", async () => {
|
|
743
|
+
// Create a coordinator session
|
|
744
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
745
|
+
saveSessionsToDb([session]);
|
|
746
|
+
|
|
747
|
+
// No current-run.txt
|
|
748
|
+
|
|
749
|
+
// Stop coordinator (should succeed without errors)
|
|
750
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
751
|
+
await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
|
|
752
|
+
|
|
753
|
+
// Verify session is completed
|
|
754
|
+
const sessions = loadSessionsFromDb();
|
|
755
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("coordinator stop succeeds when current-run.txt is empty", async () => {
|
|
759
|
+
// Create a coordinator session
|
|
760
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
761
|
+
saveSessionsToDb([session]);
|
|
762
|
+
|
|
763
|
+
// Write empty current-run.txt
|
|
764
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), "");
|
|
765
|
+
|
|
766
|
+
// Stop coordinator (should succeed without errors)
|
|
767
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
768
|
+
await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
|
|
769
|
+
|
|
770
|
+
// Verify session is completed
|
|
771
|
+
const sessions = loadSessionsFromDb();
|
|
772
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("--json output includes runCompleted field", async () => {
|
|
776
|
+
// Create a coordinator session
|
|
777
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
778
|
+
saveSessionsToDb([session]);
|
|
779
|
+
|
|
780
|
+
// Create a run in RunStore
|
|
781
|
+
const dbPath = join(overstoryDir, "sessions.db");
|
|
782
|
+
const runStore = createRunStore(dbPath);
|
|
783
|
+
runStore.createRun({
|
|
784
|
+
id: "run-test-456",
|
|
785
|
+
startedAt: new Date().toISOString(),
|
|
786
|
+
coordinatorSessionId: null,
|
|
787
|
+
status: "active",
|
|
788
|
+
});
|
|
789
|
+
runStore.close();
|
|
790
|
+
|
|
791
|
+
// Write current-run.txt
|
|
792
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), "run-test-456");
|
|
793
|
+
|
|
794
|
+
// Stop coordinator with --json
|
|
795
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
796
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
797
|
+
|
|
798
|
+
// Verify output includes runCompleted: true
|
|
799
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
800
|
+
expect(parsed.runCompleted).toBe(true);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("--json output includes runCompleted:false when no run", async () => {
|
|
804
|
+
// Create a coordinator session
|
|
805
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
806
|
+
saveSessionsToDb([session]);
|
|
807
|
+
|
|
808
|
+
// No current-run.txt
|
|
809
|
+
|
|
810
|
+
// Stop coordinator with --json
|
|
811
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
812
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
813
|
+
|
|
814
|
+
// Verify output includes runCompleted: false
|
|
815
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
816
|
+
expect(parsed.runCompleted).toBe(false);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe("statusCoordinator", () => {
|
|
821
|
+
test("shows 'not running' when no session exists", async () => {
|
|
822
|
+
const { deps } = makeDeps();
|
|
823
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
824
|
+
expect(output).toContain("not running");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("--json shows running:false when no session exists", async () => {
|
|
828
|
+
const { deps } = makeDeps();
|
|
829
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
830
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
831
|
+
expect(parsed.running).toBe(false);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("shows running state when coordinator is alive", async () => {
|
|
835
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
836
|
+
saveSessionsToDb([session]);
|
|
837
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
838
|
+
|
|
839
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
840
|
+
expect(output).toContain("running");
|
|
841
|
+
expect(output).toContain(session.id);
|
|
842
|
+
expect(output).toContain("overstory-test-project-coordinator");
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test("--json shows correct fields when running", async () => {
|
|
846
|
+
const session = makeCoordinatorSession({ state: "working", pid: 99999 });
|
|
847
|
+
saveSessionsToDb([session]);
|
|
848
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
849
|
+
|
|
850
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
851
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
852
|
+
expect(parsed.running).toBe(true);
|
|
853
|
+
expect(parsed.sessionId).toBe(session.id);
|
|
854
|
+
expect(parsed.state).toBe("working");
|
|
855
|
+
expect(parsed.tmuxSession).toBe("overstory-test-project-coordinator");
|
|
856
|
+
expect(parsed.pid).toBe(99999);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("reconciles zombie: updates state when tmux is dead but session says working", async () => {
|
|
860
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
861
|
+
saveSessionsToDb([session]);
|
|
862
|
+
|
|
863
|
+
// Tmux is NOT alive — triggers zombie reconciliation
|
|
864
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
865
|
+
|
|
866
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
867
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
868
|
+
expect(parsed.running).toBe(false);
|
|
869
|
+
expect(parsed.state).toBe("zombie");
|
|
870
|
+
|
|
871
|
+
// Verify sessions.json was updated
|
|
872
|
+
const sessions = loadSessionsFromDb();
|
|
873
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("reconciles zombie for booting state too", async () => {
|
|
877
|
+
const session = makeCoordinatorSession({ state: "booting" });
|
|
878
|
+
saveSessionsToDb([session]);
|
|
879
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
880
|
+
|
|
881
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
882
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
883
|
+
expect(parsed.state).toBe("zombie");
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("does not show completed sessions as active", async () => {
|
|
887
|
+
const completed = makeCoordinatorSession({ state: "completed" });
|
|
888
|
+
saveSessionsToDb([completed]);
|
|
889
|
+
const { deps } = makeDeps();
|
|
890
|
+
|
|
891
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
892
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
893
|
+
expect(parsed.running).toBe(false);
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
describe("buildCoordinatorBeacon", () => {
|
|
898
|
+
test("is a single line (no newlines)", () => {
|
|
899
|
+
const beacon = buildCoordinatorBeacon();
|
|
900
|
+
expect(beacon).not.toContain("\n");
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("includes coordinator identity in header", () => {
|
|
904
|
+
const beacon = buildCoordinatorBeacon();
|
|
905
|
+
expect(beacon).toContain("[OVERSTORY] coordinator (coordinator)");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("includes ISO timestamp", () => {
|
|
909
|
+
const beacon = buildCoordinatorBeacon();
|
|
910
|
+
expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("includes depth and parent info", () => {
|
|
914
|
+
const beacon = buildCoordinatorBeacon();
|
|
915
|
+
expect(beacon).toContain("Depth: 0 | Parent: none");
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("includes persistent orchestrator role", () => {
|
|
919
|
+
const beacon = buildCoordinatorBeacon();
|
|
920
|
+
expect(beacon).toContain("Role: persistent orchestrator");
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test("includes startup instructions", () => {
|
|
924
|
+
const beacon = buildCoordinatorBeacon();
|
|
925
|
+
expect(beacon).toContain("mulch prime");
|
|
926
|
+
expect(beacon).toContain("overstory mail check --agent coordinator");
|
|
927
|
+
expect(beacon).toContain("bd ready");
|
|
928
|
+
expect(beacon).toContain("overstory group status");
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("defaults to bd ready when no cliName provided", () => {
|
|
932
|
+
const beacon = buildCoordinatorBeacon();
|
|
933
|
+
expect(beacon).toContain("bd ready");
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("uses sd ready when cliName is sd", () => {
|
|
937
|
+
const beacon = buildCoordinatorBeacon("sd");
|
|
938
|
+
expect(beacon).toContain("sd ready");
|
|
939
|
+
expect(beacon).not.toContain("bd ready");
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("includes hierarchy enforcement instruction", () => {
|
|
943
|
+
const beacon = buildCoordinatorBeacon();
|
|
944
|
+
expect(beacon).toContain("ONLY spawn leads");
|
|
945
|
+
expect(beacon).toContain("NEVER spawn non-lead agents directly");
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test("includes delegation instruction", () => {
|
|
949
|
+
const beacon = buildCoordinatorBeacon();
|
|
950
|
+
expect(beacon).toContain("DELEGATION");
|
|
951
|
+
expect(beacon).toContain("spawn a lead who will spawn scouts");
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test("parts are joined with em-dash separator", () => {
|
|
955
|
+
const beacon = buildCoordinatorBeacon();
|
|
956
|
+
// Should have exactly 4 " — " separators (5 parts)
|
|
957
|
+
const dashes = beacon.split(" — ");
|
|
958
|
+
expect(dashes).toHaveLength(5);
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
describe("resolveAttach", () => {
|
|
963
|
+
test("--attach flag forces attach regardless of TTY", () => {
|
|
964
|
+
expect(resolveAttach(["--attach"], false)).toBe(true);
|
|
965
|
+
expect(resolveAttach(["--attach"], true)).toBe(true);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("--no-attach flag forces no attach regardless of TTY", () => {
|
|
969
|
+
expect(resolveAttach(["--no-attach"], false)).toBe(false);
|
|
970
|
+
expect(resolveAttach(["--no-attach"], true)).toBe(false);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("--attach takes precedence when both flags are present", () => {
|
|
974
|
+
expect(resolveAttach(["--attach", "--no-attach"], false)).toBe(true);
|
|
975
|
+
expect(resolveAttach(["--attach", "--no-attach"], true)).toBe(true);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("defaults to TTY state when no flag is set", () => {
|
|
979
|
+
expect(resolveAttach([], true)).toBe(true);
|
|
980
|
+
expect(resolveAttach([], false)).toBe(false);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test("works with other flags present", () => {
|
|
984
|
+
expect(resolveAttach(["--json", "--attach"], false)).toBe(true);
|
|
985
|
+
expect(resolveAttach(["--json", "--no-attach"], true)).toBe(false);
|
|
986
|
+
expect(resolveAttach(["--json"], true)).toBe(true);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
describe("watchdog integration", () => {
|
|
991
|
+
describe("startCoordinator with --watchdog", () => {
|
|
992
|
+
test("calls watchdog.start() when --watchdog flag is present", async () => {
|
|
993
|
+
const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
|
|
994
|
+
const originalSleep = Bun.sleep;
|
|
995
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
996
|
+
|
|
997
|
+
try {
|
|
998
|
+
await captureStdout(() => coordinatorCommand(["start", "--watchdog", "--json"], deps));
|
|
999
|
+
} finally {
|
|
1000
|
+
Bun.sleep = originalSleep;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
expect(watchdogCalls?.start).toBe(1);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test("does NOT call watchdog.start() when --watchdog flag is absent", async () => {
|
|
1007
|
+
const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
|
|
1008
|
+
const originalSleep = Bun.sleep;
|
|
1009
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1010
|
+
|
|
1011
|
+
try {
|
|
1012
|
+
await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1013
|
+
} finally {
|
|
1014
|
+
Bun.sleep = originalSleep;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("--json output includes watchdog field when --watchdog is present and succeeds", async () => {
|
|
1021
|
+
const { deps } = makeDeps({}, { startSuccess: true });
|
|
1022
|
+
const originalSleep = Bun.sleep;
|
|
1023
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1024
|
+
|
|
1025
|
+
let output: string;
|
|
1026
|
+
try {
|
|
1027
|
+
output = await captureStdout(() =>
|
|
1028
|
+
coordinatorCommand(["start", "--watchdog", "--json"], deps),
|
|
1029
|
+
);
|
|
1030
|
+
} finally {
|
|
1031
|
+
Bun.sleep = originalSleep;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1035
|
+
expect(parsed.watchdog).toBe(true);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("--json output includes watchdog:false when --watchdog is present but start fails", async () => {
|
|
1039
|
+
const { deps } = makeDeps({}, { startSuccess: false });
|
|
1040
|
+
const originalSleep = Bun.sleep;
|
|
1041
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1042
|
+
|
|
1043
|
+
let output: string;
|
|
1044
|
+
try {
|
|
1045
|
+
output = await captureStdout(() =>
|
|
1046
|
+
coordinatorCommand(["start", "--watchdog", "--json"], deps),
|
|
1047
|
+
);
|
|
1048
|
+
} finally {
|
|
1049
|
+
Bun.sleep = originalSleep;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1053
|
+
expect(parsed.watchdog).toBe(false);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("--json output includes watchdog:false when --watchdog is absent", async () => {
|
|
1057
|
+
const { deps } = makeDeps({}, { startSuccess: true });
|
|
1058
|
+
const originalSleep = Bun.sleep;
|
|
1059
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1060
|
+
|
|
1061
|
+
let output: string;
|
|
1062
|
+
try {
|
|
1063
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1064
|
+
} finally {
|
|
1065
|
+
Bun.sleep = originalSleep;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1069
|
+
expect(parsed.watchdog).toBe(false);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("text output includes watchdog PID when --watchdog succeeds", async () => {
|
|
1073
|
+
const { deps } = makeDeps({}, { startSuccess: true });
|
|
1074
|
+
const originalSleep = Bun.sleep;
|
|
1075
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1076
|
+
|
|
1077
|
+
let output: string;
|
|
1078
|
+
try {
|
|
1079
|
+
output = await captureStdout(() =>
|
|
1080
|
+
coordinatorCommand(["start", "--watchdog", "--no-attach"], deps),
|
|
1081
|
+
);
|
|
1082
|
+
} finally {
|
|
1083
|
+
Bun.sleep = originalSleep;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
expect(output).toContain("Watchdog: started (PID 88888)");
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
describe("stopCoordinator watchdog cleanup", () => {
|
|
1091
|
+
test("always calls watchdog.stop() when stopping coordinator", async () => {
|
|
1092
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1093
|
+
saveSessionsToDb([session]);
|
|
1094
|
+
const { deps, watchdogCalls } = makeDeps(
|
|
1095
|
+
{ "overstory-test-project-coordinator": true },
|
|
1096
|
+
{ stopSuccess: true },
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1100
|
+
|
|
1101
|
+
expect(watchdogCalls?.stop).toBe(1);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test("--json output includes watchdogStopped:true when watchdog was running", async () => {
|
|
1105
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1106
|
+
saveSessionsToDb([session]);
|
|
1107
|
+
const { deps } = makeDeps(
|
|
1108
|
+
{ "overstory-test-project-coordinator": true },
|
|
1109
|
+
{ stopSuccess: true },
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1113
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1114
|
+
expect(parsed.watchdogStopped).toBe(true);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
test("--json output includes watchdogStopped:false when no watchdog was running", async () => {
|
|
1118
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1119
|
+
saveSessionsToDb([session]);
|
|
1120
|
+
const { deps } = makeDeps(
|
|
1121
|
+
{ "overstory-test-project-coordinator": true },
|
|
1122
|
+
{ stopSuccess: false },
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1125
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1126
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1127
|
+
expect(parsed.watchdogStopped).toBe(false);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test("text output shows 'Watchdog stopped' when watchdog was running", async () => {
|
|
1131
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1132
|
+
saveSessionsToDb([session]);
|
|
1133
|
+
const { deps } = makeDeps(
|
|
1134
|
+
{ "overstory-test-project-coordinator": true },
|
|
1135
|
+
{ stopSuccess: true },
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1139
|
+
expect(output).toContain("Watchdog stopped");
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
test("text output shows 'No watchdog running' when no watchdog was running", async () => {
|
|
1143
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1144
|
+
saveSessionsToDb([session]);
|
|
1145
|
+
const { deps } = makeDeps(
|
|
1146
|
+
{ "overstory-test-project-coordinator": true },
|
|
1147
|
+
{ stopSuccess: false },
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1151
|
+
expect(output).toContain("No watchdog running");
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
describe("statusCoordinator watchdog state", () => {
|
|
1156
|
+
test("includes watchdogRunning in JSON output when coordinator is running", async () => {
|
|
1157
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1158
|
+
saveSessionsToDb([session]);
|
|
1159
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, { running: true });
|
|
1160
|
+
|
|
1161
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1162
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1163
|
+
expect(parsed.watchdogRunning).toBe(true);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test("includes watchdogRunning:false in JSON output when watchdog is not running", async () => {
|
|
1167
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1168
|
+
saveSessionsToDb([session]);
|
|
1169
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, { running: false });
|
|
1170
|
+
|
|
1171
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1172
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1173
|
+
expect(parsed.watchdogRunning).toBe(false);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test("text output shows watchdog status when coordinator is running", async () => {
|
|
1177
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1178
|
+
saveSessionsToDb([session]);
|
|
1179
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, { running: true });
|
|
1180
|
+
|
|
1181
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1182
|
+
expect(output).toContain("Watchdog: running");
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("text output shows 'not running' when watchdog is not running", async () => {
|
|
1186
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1187
|
+
saveSessionsToDb([session]);
|
|
1188
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, { running: false });
|
|
1189
|
+
|
|
1190
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1191
|
+
expect(output).toContain("Watchdog: not running");
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("includes watchdogRunning in JSON output when coordinator is not running", async () => {
|
|
1195
|
+
const { deps } = makeDeps({}, { running: true });
|
|
1196
|
+
|
|
1197
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1198
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1199
|
+
expect(parsed.running).toBe(false);
|
|
1200
|
+
expect(parsed.watchdogRunning).toBe(true);
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
describe("COORDINATOR_HELP", () => {
|
|
1205
|
+
test("help text includes --watchdog flag", async () => {
|
|
1206
|
+
const output = await captureStdout(() => coordinatorCommand(["--help"]));
|
|
1207
|
+
expect(output).toContain("--watchdog");
|
|
1208
|
+
expect(output).toContain("Auto-start watchdog daemon with coordinator");
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
describe("monitor integration", () => {
|
|
1214
|
+
describe("startCoordinator with --monitor", () => {
|
|
1215
|
+
test("calls monitor.start() when --monitor flag is present", async () => {
|
|
1216
|
+
const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
|
|
1217
|
+
const originalSleep = Bun.sleep;
|
|
1218
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1219
|
+
|
|
1220
|
+
try {
|
|
1221
|
+
await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
|
|
1222
|
+
} finally {
|
|
1223
|
+
Bun.sleep = originalSleep;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
expect(monitorCalls?.start).toBe(1);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
test("does NOT call monitor.start() when --monitor flag is absent", async () => {
|
|
1230
|
+
const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
|
|
1231
|
+
const originalSleep = Bun.sleep;
|
|
1232
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1233
|
+
|
|
1234
|
+
try {
|
|
1235
|
+
await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1236
|
+
} finally {
|
|
1237
|
+
Bun.sleep = originalSleep;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
expect(monitorCalls?.start).toBe(0);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
test("--json output includes monitor field when --monitor is present and succeeds", async () => {
|
|
1244
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1245
|
+
const originalSleep = Bun.sleep;
|
|
1246
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1247
|
+
|
|
1248
|
+
let output: string;
|
|
1249
|
+
try {
|
|
1250
|
+
output = await captureStdout(() =>
|
|
1251
|
+
coordinatorCommand(["start", "--monitor", "--json"], deps),
|
|
1252
|
+
);
|
|
1253
|
+
} finally {
|
|
1254
|
+
Bun.sleep = originalSleep;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1258
|
+
expect(parsed.monitor).toBe(true);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test("--json output includes monitor:false when --monitor is present but start fails", async () => {
|
|
1262
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: false });
|
|
1263
|
+
const originalSleep = Bun.sleep;
|
|
1264
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1265
|
+
|
|
1266
|
+
let output: string;
|
|
1267
|
+
try {
|
|
1268
|
+
output = await captureStdout(() =>
|
|
1269
|
+
coordinatorCommand(["start", "--monitor", "--json"], deps),
|
|
1270
|
+
);
|
|
1271
|
+
} finally {
|
|
1272
|
+
Bun.sleep = originalSleep;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1276
|
+
expect(parsed.monitor).toBe(false);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("--json output includes monitor:false when --monitor is absent", async () => {
|
|
1280
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1281
|
+
const originalSleep = Bun.sleep;
|
|
1282
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1283
|
+
|
|
1284
|
+
let output: string;
|
|
1285
|
+
try {
|
|
1286
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1287
|
+
} finally {
|
|
1288
|
+
Bun.sleep = originalSleep;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1292
|
+
expect(parsed.monitor).toBe(false);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test("text output includes monitor PID when --monitor succeeds", async () => {
|
|
1296
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1297
|
+
const originalSleep = Bun.sleep;
|
|
1298
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1299
|
+
|
|
1300
|
+
let output: string;
|
|
1301
|
+
try {
|
|
1302
|
+
output = await captureStdout(() =>
|
|
1303
|
+
coordinatorCommand(["start", "--monitor", "--no-attach"], deps),
|
|
1304
|
+
);
|
|
1305
|
+
} finally {
|
|
1306
|
+
Bun.sleep = originalSleep;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
expect(output).toContain("Monitor: started (PID 77777)");
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("does NOT call monitor.start() when tier2Enabled is false", async () => {
|
|
1313
|
+
// Override config with tier2Enabled: false
|
|
1314
|
+
await Bun.write(
|
|
1315
|
+
join(overstoryDir, "config.yaml"),
|
|
1316
|
+
[
|
|
1317
|
+
"project:",
|
|
1318
|
+
" name: test-project",
|
|
1319
|
+
` root: ${tempDir}`,
|
|
1320
|
+
" canonicalBranch: main",
|
|
1321
|
+
"watchdog:",
|
|
1322
|
+
" tier2Enabled: false",
|
|
1323
|
+
].join("\n"),
|
|
1324
|
+
);
|
|
1325
|
+
const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
|
|
1326
|
+
const originalSleep = Bun.sleep;
|
|
1327
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1328
|
+
|
|
1329
|
+
try {
|
|
1330
|
+
await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
|
|
1331
|
+
} finally {
|
|
1332
|
+
Bun.sleep = originalSleep;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
expect(monitorCalls?.start).toBe(0);
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("text output shows skipped message when tier2Enabled is false", async () => {
|
|
1339
|
+
// Override config with tier2Enabled: false
|
|
1340
|
+
await Bun.write(
|
|
1341
|
+
join(overstoryDir, "config.yaml"),
|
|
1342
|
+
[
|
|
1343
|
+
"project:",
|
|
1344
|
+
" name: test-project",
|
|
1345
|
+
` root: ${tempDir}`,
|
|
1346
|
+
" canonicalBranch: main",
|
|
1347
|
+
"watchdog:",
|
|
1348
|
+
" tier2Enabled: false",
|
|
1349
|
+
].join("\n"),
|
|
1350
|
+
);
|
|
1351
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1352
|
+
const originalSleep = Bun.sleep;
|
|
1353
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1354
|
+
|
|
1355
|
+
let stderrOutput = "";
|
|
1356
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
1357
|
+
process.stderr.write = (chunk: string | Uint8Array) => {
|
|
1358
|
+
stderrOutput += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
|
1359
|
+
return true;
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
try {
|
|
1363
|
+
await captureStdout(() => coordinatorCommand(["start", "--monitor", "--no-attach"], deps));
|
|
1364
|
+
} finally {
|
|
1365
|
+
Bun.sleep = originalSleep;
|
|
1366
|
+
process.stderr.write = origStderrWrite;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
expect(stderrOutput).toContain("skipped");
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
describe("stopCoordinator monitor cleanup", () => {
|
|
1374
|
+
test("always calls monitor.stop() when stopping coordinator", async () => {
|
|
1375
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1376
|
+
saveSessionsToDb([session]);
|
|
1377
|
+
const { deps, monitorCalls } = makeDeps(
|
|
1378
|
+
{ "overstory-test-project-coordinator": true },
|
|
1379
|
+
undefined,
|
|
1380
|
+
{ stopSuccess: true },
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1384
|
+
|
|
1385
|
+
expect(monitorCalls?.stop).toBe(1);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test("--json output includes monitorStopped:true when monitor was running", async () => {
|
|
1389
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1390
|
+
saveSessionsToDb([session]);
|
|
1391
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1392
|
+
stopSuccess: true,
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1396
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1397
|
+
expect(parsed.monitorStopped).toBe(true);
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
test("--json output includes monitorStopped:false when no monitor was running", async () => {
|
|
1401
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1402
|
+
saveSessionsToDb([session]);
|
|
1403
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1404
|
+
stopSuccess: false,
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1408
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1409
|
+
expect(parsed.monitorStopped).toBe(false);
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
test("text output shows 'Monitor stopped' when monitor was running", async () => {
|
|
1413
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1414
|
+
saveSessionsToDb([session]);
|
|
1415
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1416
|
+
stopSuccess: true,
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1420
|
+
expect(output).toContain("Monitor stopped");
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
test("text output shows 'No monitor running' when no monitor was running", async () => {
|
|
1424
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1425
|
+
saveSessionsToDb([session]);
|
|
1426
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1427
|
+
stopSuccess: false,
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1431
|
+
expect(output).toContain("No monitor running");
|
|
1432
|
+
});
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
describe("statusCoordinator monitor state", () => {
|
|
1436
|
+
test("includes monitorRunning in JSON output when coordinator is running", async () => {
|
|
1437
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1438
|
+
saveSessionsToDb([session]);
|
|
1439
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1440
|
+
running: true,
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1444
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1445
|
+
expect(parsed.monitorRunning).toBe(true);
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
test("includes monitorRunning:false in JSON output when monitor is not running", async () => {
|
|
1449
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1450
|
+
saveSessionsToDb([session]);
|
|
1451
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1452
|
+
running: false,
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1456
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1457
|
+
expect(parsed.monitorRunning).toBe(false);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
test("text output shows monitor status when coordinator is running", async () => {
|
|
1461
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1462
|
+
saveSessionsToDb([session]);
|
|
1463
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1464
|
+
running: true,
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1468
|
+
expect(output).toContain("Monitor: running");
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
test("text output shows 'not running' when monitor is not running", async () => {
|
|
1472
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1473
|
+
saveSessionsToDb([session]);
|
|
1474
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true }, undefined, {
|
|
1475
|
+
running: false,
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1479
|
+
expect(output).toContain("Monitor: not running");
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
test("includes monitorRunning in JSON output when coordinator is not running", async () => {
|
|
1483
|
+
const { deps } = makeDeps({}, undefined, { running: true });
|
|
1484
|
+
|
|
1485
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1486
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1487
|
+
expect(parsed.running).toBe(false);
|
|
1488
|
+
expect(parsed.monitorRunning).toBe(true);
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
describe("COORDINATOR_HELP", () => {
|
|
1493
|
+
test("help text includes --monitor flag", async () => {
|
|
1494
|
+
const output = await captureStdout(() => coordinatorCommand(["--help"]));
|
|
1495
|
+
expect(output).toContain("--monitor");
|
|
1496
|
+
expect(output).toContain("Auto-start monitor agent (Tier 2) with coordinator");
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
describe("SessionStore round-trip", () => {
|
|
1502
|
+
test("returns empty array when no sessions exist", () => {
|
|
1503
|
+
const sessions = loadSessionsFromDb();
|
|
1504
|
+
expect(sessions).toEqual([]);
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
test("save then load round-trips correctly", () => {
|
|
1508
|
+
const original = [makeCoordinatorSession()];
|
|
1509
|
+
saveSessionsToDb(original);
|
|
1510
|
+
const loaded = loadSessionsFromDb();
|
|
1511
|
+
|
|
1512
|
+
expect(loaded).toHaveLength(1);
|
|
1513
|
+
expect(loaded[0]?.agentName).toBe("coordinator");
|
|
1514
|
+
expect(loaded[0]?.capability).toBe("coordinator");
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
test("sessions.db is created after save", () => {
|
|
1518
|
+
saveSessionsToDb([makeCoordinatorSession()]);
|
|
1519
|
+
const dbPath = join(overstoryDir, "sessions.db");
|
|
1520
|
+
const exists = Bun.file(dbPath).size > 0;
|
|
1521
|
+
expect(exists).toBe(true);
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
describe("isRunningAsRoot (imported from sling)", () => {
|
|
1526
|
+
test("is accessible from coordinator test file", () => {
|
|
1527
|
+
expect(isRunningAsRoot(() => 0)).toBe(true);
|
|
1528
|
+
expect(isRunningAsRoot(() => 1000)).toBe(false);
|
|
1529
|
+
});
|
|
1530
|
+
});
|