@os-eco/overstory-cli 0.8.4 → 0.8.6
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 +4 -2
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/manifest.test.ts +33 -8
- package/src/agents/manifest.ts +4 -3
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +420 -1
- package/src/commands/coordinator.ts +173 -1
- package/src/commands/init.test.ts +137 -0
- package/src/commands/init.ts +57 -1
- package/src/commands/inspect.test.ts +398 -1
- package/src/commands/inspect.ts +234 -0
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.ts +312 -322
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/events/tailer.test.ts +461 -0
- package/src/events/tailer.ts +235 -0
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +243 -19
- package/src/merge/resolver.ts +235 -95
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +185 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +61 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.test.ts +30 -0
- package/src/runtimes/sapling.ts +27 -24
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +19 -0
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +123 -23
- package/src/worktree/manager.test.ts +65 -1
- package/src/worktree/manager.ts +36 -0
|
@@ -17,13 +17,14 @@ import { join } from "node:path";
|
|
|
17
17
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
18
|
import { createMailStore } from "../mail/store.ts";
|
|
19
19
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
20
|
-
import { createRunStore } from "../sessions/store.ts";
|
|
20
|
+
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
21
21
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
22
22
|
import type { AgentSession } from "../types.ts";
|
|
23
23
|
import {
|
|
24
24
|
askCoordinator,
|
|
25
25
|
buildCoordinatorBeacon,
|
|
26
26
|
type CoordinatorDeps,
|
|
27
|
+
checkComplete,
|
|
27
28
|
coordinatorCommand,
|
|
28
29
|
createCoordinatorCommand,
|
|
29
30
|
resolveAttach,
|
|
@@ -2104,3 +2105,421 @@ describe("askCoordinator", () => {
|
|
|
2104
2105
|
expect(subcommandNames).toContain("ask");
|
|
2105
2106
|
});
|
|
2106
2107
|
});
|
|
2108
|
+
|
|
2109
|
+
// ─── checkComplete ─────────────────────────────────────────────────────────
|
|
2110
|
+
|
|
2111
|
+
describe("checkComplete", () => {
|
|
2112
|
+
test("all triggers disabled → complete: false", async () => {
|
|
2113
|
+
// Default config has no coordinator section → all triggers default to false
|
|
2114
|
+
const result = await checkComplete({ json: false });
|
|
2115
|
+
expect(result.complete).toBe(false);
|
|
2116
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(false);
|
|
2117
|
+
expect(result.triggers.taskTrackerEmpty.enabled).toBe(false);
|
|
2118
|
+
expect(result.triggers.onShutdownSignal.enabled).toBe(false);
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
test("allAgentsDone met when all non-coordinator agents completed", async () => {
|
|
2122
|
+
// Enable allAgentsDone in config
|
|
2123
|
+
await Bun.write(
|
|
2124
|
+
join(overstoryDir, "config.yaml"),
|
|
2125
|
+
[
|
|
2126
|
+
"project:",
|
|
2127
|
+
" name: test-project",
|
|
2128
|
+
` root: ${tempDir}`,
|
|
2129
|
+
" canonicalBranch: main",
|
|
2130
|
+
"coordinator:",
|
|
2131
|
+
" exitTriggers:",
|
|
2132
|
+
" allAgentsDone: true",
|
|
2133
|
+
" taskTrackerEmpty: false",
|
|
2134
|
+
" onShutdownSignal: false",
|
|
2135
|
+
].join("\n"),
|
|
2136
|
+
);
|
|
2137
|
+
|
|
2138
|
+
// Write current-run.txt
|
|
2139
|
+
const runId = `run-${Date.now()}`;
|
|
2140
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
2141
|
+
|
|
2142
|
+
// Create sessions.db with two completed agents
|
|
2143
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
2144
|
+
try {
|
|
2145
|
+
const base: AgentSession = {
|
|
2146
|
+
id: "s1",
|
|
2147
|
+
agentName: "builder-1",
|
|
2148
|
+
capability: "builder",
|
|
2149
|
+
worktreePath: tempDir,
|
|
2150
|
+
branchName: "feat/x",
|
|
2151
|
+
taskId: "t1",
|
|
2152
|
+
tmuxSession: "tmux-1",
|
|
2153
|
+
state: "completed",
|
|
2154
|
+
pid: null,
|
|
2155
|
+
parentAgent: "coordinator",
|
|
2156
|
+
depth: 1,
|
|
2157
|
+
runId,
|
|
2158
|
+
startedAt: new Date().toISOString(),
|
|
2159
|
+
lastActivity: new Date().toISOString(),
|
|
2160
|
+
escalationLevel: 0,
|
|
2161
|
+
stalledSince: null,
|
|
2162
|
+
transcriptPath: null,
|
|
2163
|
+
};
|
|
2164
|
+
store.upsert(base);
|
|
2165
|
+
store.upsert({ ...base, id: "s2", agentName: "builder-2" });
|
|
2166
|
+
} finally {
|
|
2167
|
+
store.close();
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const result = await checkComplete({ json: false });
|
|
2171
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2172
|
+
expect(result.triggers.allAgentsDone.met).toBe(true);
|
|
2173
|
+
expect(result.complete).toBe(true);
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
test("allAgentsDone not met when agents still working", async () => {
|
|
2177
|
+
await Bun.write(
|
|
2178
|
+
join(overstoryDir, "config.yaml"),
|
|
2179
|
+
[
|
|
2180
|
+
"project:",
|
|
2181
|
+
" name: test-project",
|
|
2182
|
+
` root: ${tempDir}`,
|
|
2183
|
+
" canonicalBranch: main",
|
|
2184
|
+
"coordinator:",
|
|
2185
|
+
" exitTriggers:",
|
|
2186
|
+
" allAgentsDone: true",
|
|
2187
|
+
" taskTrackerEmpty: false",
|
|
2188
|
+
" onShutdownSignal: false",
|
|
2189
|
+
].join("\n"),
|
|
2190
|
+
);
|
|
2191
|
+
|
|
2192
|
+
const runId = `run-${Date.now()}`;
|
|
2193
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
2194
|
+
|
|
2195
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
2196
|
+
try {
|
|
2197
|
+
const session: AgentSession = {
|
|
2198
|
+
id: "s1",
|
|
2199
|
+
agentName: "builder-1",
|
|
2200
|
+
capability: "builder",
|
|
2201
|
+
worktreePath: tempDir,
|
|
2202
|
+
branchName: "feat/x",
|
|
2203
|
+
taskId: "t1",
|
|
2204
|
+
tmuxSession: "tmux-1",
|
|
2205
|
+
state: "working",
|
|
2206
|
+
pid: null,
|
|
2207
|
+
parentAgent: "coordinator",
|
|
2208
|
+
depth: 1,
|
|
2209
|
+
runId,
|
|
2210
|
+
startedAt: new Date().toISOString(),
|
|
2211
|
+
lastActivity: new Date().toISOString(),
|
|
2212
|
+
escalationLevel: 0,
|
|
2213
|
+
stalledSince: null,
|
|
2214
|
+
transcriptPath: null,
|
|
2215
|
+
};
|
|
2216
|
+
store.upsert(session);
|
|
2217
|
+
} finally {
|
|
2218
|
+
store.close();
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const result = await checkComplete({ json: false });
|
|
2222
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2223
|
+
expect(result.triggers.allAgentsDone.met).toBe(false);
|
|
2224
|
+
expect(result.complete).toBe(false);
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
test("allAgentsDone filters out coordinator session", async () => {
|
|
2228
|
+
await Bun.write(
|
|
2229
|
+
join(overstoryDir, "config.yaml"),
|
|
2230
|
+
[
|
|
2231
|
+
"project:",
|
|
2232
|
+
" name: test-project",
|
|
2233
|
+
` root: ${tempDir}`,
|
|
2234
|
+
" canonicalBranch: main",
|
|
2235
|
+
"coordinator:",
|
|
2236
|
+
" exitTriggers:",
|
|
2237
|
+
" allAgentsDone: true",
|
|
2238
|
+
" taskTrackerEmpty: false",
|
|
2239
|
+
" onShutdownSignal: false",
|
|
2240
|
+
].join("\n"),
|
|
2241
|
+
);
|
|
2242
|
+
|
|
2243
|
+
const runId = `run-${Date.now()}`;
|
|
2244
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
2245
|
+
|
|
2246
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
2247
|
+
try {
|
|
2248
|
+
// coordinator session (should be excluded)
|
|
2249
|
+
store.upsert({
|
|
2250
|
+
id: "coord",
|
|
2251
|
+
agentName: "coordinator",
|
|
2252
|
+
capability: "coordinator",
|
|
2253
|
+
worktreePath: tempDir,
|
|
2254
|
+
branchName: "main",
|
|
2255
|
+
taskId: "",
|
|
2256
|
+
tmuxSession: "tmux-coord",
|
|
2257
|
+
state: "working",
|
|
2258
|
+
pid: null,
|
|
2259
|
+
parentAgent: null,
|
|
2260
|
+
depth: 0,
|
|
2261
|
+
runId,
|
|
2262
|
+
startedAt: new Date().toISOString(),
|
|
2263
|
+
lastActivity: new Date().toISOString(),
|
|
2264
|
+
escalationLevel: 0,
|
|
2265
|
+
stalledSince: null,
|
|
2266
|
+
transcriptPath: null,
|
|
2267
|
+
});
|
|
2268
|
+
// worker session that is completed
|
|
2269
|
+
store.upsert({
|
|
2270
|
+
id: "worker",
|
|
2271
|
+
agentName: "builder-1",
|
|
2272
|
+
capability: "builder",
|
|
2273
|
+
worktreePath: tempDir,
|
|
2274
|
+
branchName: "feat/x",
|
|
2275
|
+
taskId: "t1",
|
|
2276
|
+
tmuxSession: "tmux-w",
|
|
2277
|
+
state: "completed",
|
|
2278
|
+
pid: null,
|
|
2279
|
+
parentAgent: "coordinator",
|
|
2280
|
+
depth: 1,
|
|
2281
|
+
runId,
|
|
2282
|
+
startedAt: new Date().toISOString(),
|
|
2283
|
+
lastActivity: new Date().toISOString(),
|
|
2284
|
+
escalationLevel: 0,
|
|
2285
|
+
stalledSince: null,
|
|
2286
|
+
transcriptPath: null,
|
|
2287
|
+
});
|
|
2288
|
+
} finally {
|
|
2289
|
+
store.close();
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const result = await checkComplete({ json: false });
|
|
2293
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2294
|
+
// coordinator is filtered out; only the builder counts → all done
|
|
2295
|
+
expect(result.triggers.allAgentsDone.met).toBe(true);
|
|
2296
|
+
expect(result.complete).toBe(true);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
test("onShutdownSignal met when shutdown mail exists", async () => {
|
|
2300
|
+
await Bun.write(
|
|
2301
|
+
join(overstoryDir, "config.yaml"),
|
|
2302
|
+
[
|
|
2303
|
+
"project:",
|
|
2304
|
+
" name: test-project",
|
|
2305
|
+
` root: ${tempDir}`,
|
|
2306
|
+
" canonicalBranch: main",
|
|
2307
|
+
"coordinator:",
|
|
2308
|
+
" exitTriggers:",
|
|
2309
|
+
" allAgentsDone: false",
|
|
2310
|
+
" taskTrackerEmpty: false",
|
|
2311
|
+
" onShutdownSignal: true",
|
|
2312
|
+
].join("\n"),
|
|
2313
|
+
);
|
|
2314
|
+
|
|
2315
|
+
// Insert a shutdown message into mail.db
|
|
2316
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
2317
|
+
try {
|
|
2318
|
+
mailStore.insert({
|
|
2319
|
+
id: "",
|
|
2320
|
+
from: "greenhouse",
|
|
2321
|
+
to: "coordinator",
|
|
2322
|
+
subject: "shutdown",
|
|
2323
|
+
body: "All work done, please shutdown",
|
|
2324
|
+
type: "status",
|
|
2325
|
+
priority: "normal",
|
|
2326
|
+
threadId: null,
|
|
2327
|
+
payload: null,
|
|
2328
|
+
});
|
|
2329
|
+
} finally {
|
|
2330
|
+
mailStore.close();
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
const result = await checkComplete({ json: false });
|
|
2334
|
+
expect(result.triggers.onShutdownSignal.enabled).toBe(true);
|
|
2335
|
+
expect(result.triggers.onShutdownSignal.met).toBe(true);
|
|
2336
|
+
expect(result.complete).toBe(true);
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
test("overall complete false when only one of two enabled triggers is met", async () => {
|
|
2340
|
+
// Enable allAgentsDone + onShutdownSignal; satisfy only onShutdownSignal
|
|
2341
|
+
await Bun.write(
|
|
2342
|
+
join(overstoryDir, "config.yaml"),
|
|
2343
|
+
[
|
|
2344
|
+
"project:",
|
|
2345
|
+
" name: test-project",
|
|
2346
|
+
` root: ${tempDir}`,
|
|
2347
|
+
" canonicalBranch: main",
|
|
2348
|
+
"coordinator:",
|
|
2349
|
+
" exitTriggers:",
|
|
2350
|
+
" allAgentsDone: true",
|
|
2351
|
+
" taskTrackerEmpty: false",
|
|
2352
|
+
" onShutdownSignal: true",
|
|
2353
|
+
].join("\n"),
|
|
2354
|
+
);
|
|
2355
|
+
|
|
2356
|
+
// Write current-run.txt but no sessions → allAgentsDone not met (empty run)
|
|
2357
|
+
const runId = `run-${Date.now()}`;
|
|
2358
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
2359
|
+
// Sessions DB will be created empty — no agents → allAgentsDone.met = false (length === 0)
|
|
2360
|
+
|
|
2361
|
+
// Insert shutdown mail so onShutdownSignal is met
|
|
2362
|
+
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
2363
|
+
try {
|
|
2364
|
+
mailStore.insert({
|
|
2365
|
+
id: "",
|
|
2366
|
+
from: "operator",
|
|
2367
|
+
to: "coordinator",
|
|
2368
|
+
subject: "shutdown now",
|
|
2369
|
+
body: "Please shutdown",
|
|
2370
|
+
type: "status",
|
|
2371
|
+
priority: "normal",
|
|
2372
|
+
threadId: null,
|
|
2373
|
+
payload: null,
|
|
2374
|
+
});
|
|
2375
|
+
} finally {
|
|
2376
|
+
mailStore.close();
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const result = await checkComplete({ json: false });
|
|
2380
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2381
|
+
expect(result.triggers.allAgentsDone.met).toBe(false);
|
|
2382
|
+
expect(result.triggers.onShutdownSignal.enabled).toBe(true);
|
|
2383
|
+
expect(result.triggers.onShutdownSignal.met).toBe(true);
|
|
2384
|
+
// Both must be met → false
|
|
2385
|
+
expect(result.complete).toBe(false);
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
test("allAgentsDone false when merge queue has pending branches", async () => {
|
|
2389
|
+
await Bun.write(
|
|
2390
|
+
join(overstoryDir, "config.yaml"),
|
|
2391
|
+
[
|
|
2392
|
+
"project:",
|
|
2393
|
+
" name: test-project",
|
|
2394
|
+
` root: ${tempDir}`,
|
|
2395
|
+
" canonicalBranch: main",
|
|
2396
|
+
"coordinator:",
|
|
2397
|
+
" exitTriggers:",
|
|
2398
|
+
" allAgentsDone: true",
|
|
2399
|
+
" taskTrackerEmpty: false",
|
|
2400
|
+
" onShutdownSignal: false",
|
|
2401
|
+
].join("\n"),
|
|
2402
|
+
);
|
|
2403
|
+
|
|
2404
|
+
const runId = `run-${Date.now()}`;
|
|
2405
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
2406
|
+
|
|
2407
|
+
// All agent sessions completed
|
|
2408
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
2409
|
+
try {
|
|
2410
|
+
store.upsert({
|
|
2411
|
+
id: "s1",
|
|
2412
|
+
agentName: "lead-1",
|
|
2413
|
+
capability: "lead",
|
|
2414
|
+
worktreePath: tempDir,
|
|
2415
|
+
branchName: "overstory/lead-1/task-1",
|
|
2416
|
+
taskId: "task-1",
|
|
2417
|
+
tmuxSession: "tmux-1",
|
|
2418
|
+
state: "completed",
|
|
2419
|
+
pid: null,
|
|
2420
|
+
parentAgent: "coordinator",
|
|
2421
|
+
depth: 1,
|
|
2422
|
+
runId,
|
|
2423
|
+
startedAt: new Date().toISOString(),
|
|
2424
|
+
lastActivity: new Date().toISOString(),
|
|
2425
|
+
escalationLevel: 0,
|
|
2426
|
+
stalledSince: null,
|
|
2427
|
+
transcriptPath: null,
|
|
2428
|
+
});
|
|
2429
|
+
} finally {
|
|
2430
|
+
store.close();
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// Merge queue has a pending entry — lead branch not yet merged
|
|
2434
|
+
const { createMergeQueue } = await import("../merge/queue.ts");
|
|
2435
|
+
const queue = createMergeQueue(join(overstoryDir, "merge-queue.db"));
|
|
2436
|
+
try {
|
|
2437
|
+
queue.enqueue({
|
|
2438
|
+
branchName: "overstory/lead-1/task-1",
|
|
2439
|
+
taskId: "task-1",
|
|
2440
|
+
agentName: "lead-1",
|
|
2441
|
+
filesModified: ["src/foo.ts"],
|
|
2442
|
+
});
|
|
2443
|
+
} finally {
|
|
2444
|
+
queue.close();
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
const result = await checkComplete({ json: false });
|
|
2448
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2449
|
+
expect(result.triggers.allAgentsDone.met).toBe(false);
|
|
2450
|
+
expect(result.triggers.allAgentsDone.detail).toInclude("pending merge");
|
|
2451
|
+
expect(result.triggers.allAgentsDone.detail).toInclude("overstory/lead-1/task-1");
|
|
2452
|
+
expect(result.complete).toBe(false);
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
test("allAgentsDone true when all agents completed and merge queue is empty", async () => {
|
|
2456
|
+
await Bun.write(
|
|
2457
|
+
join(overstoryDir, "config.yaml"),
|
|
2458
|
+
[
|
|
2459
|
+
"project:",
|
|
2460
|
+
" name: test-project",
|
|
2461
|
+
` root: ${tempDir}`,
|
|
2462
|
+
" canonicalBranch: main",
|
|
2463
|
+
"coordinator:",
|
|
2464
|
+
" exitTriggers:",
|
|
2465
|
+
" allAgentsDone: true",
|
|
2466
|
+
" taskTrackerEmpty: false",
|
|
2467
|
+
" onShutdownSignal: false",
|
|
2468
|
+
].join("\n"),
|
|
2469
|
+
);
|
|
2470
|
+
|
|
2471
|
+
const runId = `run-${Date.now()}`;
|
|
2472
|
+
await Bun.write(join(overstoryDir, "current-run.txt"), runId);
|
|
2473
|
+
|
|
2474
|
+
const store = createSessionStore(join(overstoryDir, "sessions.db"));
|
|
2475
|
+
try {
|
|
2476
|
+
store.upsert({
|
|
2477
|
+
id: "s1",
|
|
2478
|
+
agentName: "lead-1",
|
|
2479
|
+
capability: "lead",
|
|
2480
|
+
worktreePath: tempDir,
|
|
2481
|
+
branchName: "overstory/lead-1/task-1",
|
|
2482
|
+
taskId: "task-1",
|
|
2483
|
+
tmuxSession: "tmux-1",
|
|
2484
|
+
state: "completed",
|
|
2485
|
+
pid: null,
|
|
2486
|
+
parentAgent: "coordinator",
|
|
2487
|
+
depth: 1,
|
|
2488
|
+
runId,
|
|
2489
|
+
startedAt: new Date().toISOString(),
|
|
2490
|
+
lastActivity: new Date().toISOString(),
|
|
2491
|
+
escalationLevel: 0,
|
|
2492
|
+
stalledSince: null,
|
|
2493
|
+
transcriptPath: null,
|
|
2494
|
+
});
|
|
2495
|
+
} finally {
|
|
2496
|
+
store.close();
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// Merge queue exists but all entries are already merged (no pending)
|
|
2500
|
+
const { createMergeQueue } = await import("../merge/queue.ts");
|
|
2501
|
+
const queue = createMergeQueue(join(overstoryDir, "merge-queue.db"));
|
|
2502
|
+
try {
|
|
2503
|
+
const entry = queue.enqueue({
|
|
2504
|
+
branchName: "overstory/lead-1/task-1",
|
|
2505
|
+
taskId: "task-1",
|
|
2506
|
+
agentName: "lead-1",
|
|
2507
|
+
filesModified: ["src/foo.ts"],
|
|
2508
|
+
});
|
|
2509
|
+
queue.updateStatus(entry.branchName, "merged", "clean-merge");
|
|
2510
|
+
} finally {
|
|
2511
|
+
queue.close();
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
const result = await checkComplete({ json: false });
|
|
2515
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2516
|
+
expect(result.triggers.allAgentsDone.met).toBe(true);
|
|
2517
|
+
expect(result.complete).toBe(true);
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
test("command registration — createCoordinatorCommand has check-complete subcommand", () => {
|
|
2521
|
+
const cmd = createCoordinatorCommand({});
|
|
2522
|
+
const subcommandNames = cmd.commands.map((c) => c.name());
|
|
2523
|
+
expect(subcommandNames).toContain("check-complete");
|
|
2524
|
+
});
|
|
2525
|
+
});
|
|
@@ -25,7 +25,7 @@ import { createMailClient } from "../mail/client.ts";
|
|
|
25
25
|
import { createMailStore } from "../mail/store.ts";
|
|
26
26
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
27
27
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
28
|
-
import { createRunStore } from "../sessions/store.ts";
|
|
28
|
+
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
29
29
|
import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
30
30
|
import type { AgentSession } from "../types.ts";
|
|
31
31
|
import { isProcessRunning } from "../watchdog/health.ts";
|
|
@@ -1054,6 +1054,170 @@ async function outputCoordinator(
|
|
|
1054
1054
|
}
|
|
1055
1055
|
}
|
|
1056
1056
|
|
|
1057
|
+
/** Per-trigger evaluation result for checkComplete. */
|
|
1058
|
+
export interface TriggerResult {
|
|
1059
|
+
enabled: boolean;
|
|
1060
|
+
met: boolean;
|
|
1061
|
+
detail: string;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** Result of `ov coordinator check-complete`. */
|
|
1065
|
+
export interface CheckCompleteResult {
|
|
1066
|
+
complete: boolean;
|
|
1067
|
+
triggers: {
|
|
1068
|
+
allAgentsDone: TriggerResult;
|
|
1069
|
+
taskTrackerEmpty: TriggerResult;
|
|
1070
|
+
onShutdownSignal: TriggerResult;
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Evaluate configured exit triggers and return per-trigger status.
|
|
1076
|
+
*
|
|
1077
|
+
* Logic:
|
|
1078
|
+
* - complete = true only if ALL enabled triggers are met
|
|
1079
|
+
* - No enabled triggers → complete: false (safety default)
|
|
1080
|
+
*/
|
|
1081
|
+
export async function checkComplete(
|
|
1082
|
+
opts: { json: boolean },
|
|
1083
|
+
deps?: CoordinatorDeps,
|
|
1084
|
+
): Promise<CheckCompleteResult> {
|
|
1085
|
+
void deps; // reserved for future DI
|
|
1086
|
+
|
|
1087
|
+
const config = await loadConfig(process.cwd());
|
|
1088
|
+
const triggers = config.coordinator?.exitTriggers ?? {
|
|
1089
|
+
allAgentsDone: false,
|
|
1090
|
+
taskTrackerEmpty: false,
|
|
1091
|
+
onShutdownSignal: false,
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
const result: CheckCompleteResult = {
|
|
1095
|
+
complete: false,
|
|
1096
|
+
triggers: {
|
|
1097
|
+
allAgentsDone: { enabled: triggers.allAgentsDone, met: false, detail: "" },
|
|
1098
|
+
taskTrackerEmpty: { enabled: triggers.taskTrackerEmpty, met: false, detail: "" },
|
|
1099
|
+
onShutdownSignal: { enabled: triggers.onShutdownSignal, met: false, detail: "" },
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
// allAgentsDone: read current-run.txt, query SessionStore
|
|
1104
|
+
if (triggers.allAgentsDone) {
|
|
1105
|
+
const runIdPath = join(config.project.root, ".overstory", "current-run.txt");
|
|
1106
|
+
const runIdFile = Bun.file(runIdPath);
|
|
1107
|
+
if (await runIdFile.exists()) {
|
|
1108
|
+
const runId = (await runIdFile.text()).trim();
|
|
1109
|
+
const sessionsDb = join(config.project.root, ".overstory", "sessions.db");
|
|
1110
|
+
const store = createSessionStore(sessionsDb);
|
|
1111
|
+
try {
|
|
1112
|
+
const sessions = store.getByRun(runId);
|
|
1113
|
+
const agentSessions = sessions.filter((s) => s.capability !== "coordinator");
|
|
1114
|
+
let allDone =
|
|
1115
|
+
agentSessions.length > 0 && agentSessions.every((s) => s.state === "completed");
|
|
1116
|
+
const states = agentSessions.map((s) => `${s.agentName}:${s.state}`);
|
|
1117
|
+
|
|
1118
|
+
// Also check the merge queue — agents may be "completed" but branches
|
|
1119
|
+
// not yet merged. This prevents premature issue closure when a builder
|
|
1120
|
+
// finishes but its lead hasn't merged yet (overstory-5c08).
|
|
1121
|
+
if (allDone) {
|
|
1122
|
+
const mergeQueuePath = join(config.project.root, ".overstory", "merge-queue.db");
|
|
1123
|
+
const mergeQueueFile = Bun.file(mergeQueuePath);
|
|
1124
|
+
if (await mergeQueueFile.exists()) {
|
|
1125
|
+
const { createMergeQueue } = await import("../merge/queue.ts");
|
|
1126
|
+
const queue = createMergeQueue(mergeQueuePath);
|
|
1127
|
+
try {
|
|
1128
|
+
const pending = queue.list("pending");
|
|
1129
|
+
if (pending.length > 0) {
|
|
1130
|
+
allDone = false;
|
|
1131
|
+
result.triggers.allAgentsDone.detail = `${pending.length} branch(es) pending merge: ${pending.map((e) => e.branchName).join(", ")}`;
|
|
1132
|
+
}
|
|
1133
|
+
} finally {
|
|
1134
|
+
queue.close();
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
result.triggers.allAgentsDone.met = allDone;
|
|
1140
|
+
if (result.triggers.allAgentsDone.detail === "") {
|
|
1141
|
+
result.triggers.allAgentsDone.detail = allDone
|
|
1142
|
+
? `All ${agentSessions.length} agents completed`
|
|
1143
|
+
: states.join(", ");
|
|
1144
|
+
}
|
|
1145
|
+
} finally {
|
|
1146
|
+
store.close();
|
|
1147
|
+
}
|
|
1148
|
+
} else {
|
|
1149
|
+
result.triggers.allAgentsDone.detail = "No current run found";
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// taskTrackerEmpty: shell out to tracker CLI
|
|
1154
|
+
if (triggers.taskTrackerEmpty) {
|
|
1155
|
+
try {
|
|
1156
|
+
const backend = await resolveBackend(config.taskTracker.backend, config.project.root);
|
|
1157
|
+
const cliName = trackerCliName(backend);
|
|
1158
|
+
const proc = Bun.spawn([cliName, "ready", "--json"], {
|
|
1159
|
+
cwd: config.project.root,
|
|
1160
|
+
stdout: "pipe",
|
|
1161
|
+
stderr: "pipe",
|
|
1162
|
+
});
|
|
1163
|
+
const exitCode = await proc.exited;
|
|
1164
|
+
const stdout = await new Response(proc.stdout).text();
|
|
1165
|
+
if (exitCode === 0) {
|
|
1166
|
+
try {
|
|
1167
|
+
const issues = JSON.parse(stdout.trim()) as unknown;
|
|
1168
|
+
const isEmpty = Array.isArray(issues) && issues.length === 0;
|
|
1169
|
+
result.triggers.taskTrackerEmpty.met = isEmpty;
|
|
1170
|
+
result.triggers.taskTrackerEmpty.detail = isEmpty
|
|
1171
|
+
? "No unblocked issues"
|
|
1172
|
+
: `${(issues as unknown[]).length} unblocked issue(s)`;
|
|
1173
|
+
} catch {
|
|
1174
|
+
const isEmpty = stdout.trim() === "" || stdout.trim() === "[]";
|
|
1175
|
+
result.triggers.taskTrackerEmpty.met = isEmpty;
|
|
1176
|
+
result.triggers.taskTrackerEmpty.detail = isEmpty
|
|
1177
|
+
? "No unblocked issues"
|
|
1178
|
+
: "Issues found";
|
|
1179
|
+
}
|
|
1180
|
+
} else {
|
|
1181
|
+
result.triggers.taskTrackerEmpty.detail = `Tracker command failed (exit ${exitCode})`;
|
|
1182
|
+
}
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
result.triggers.taskTrackerEmpty.detail = `Tracker error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// onShutdownSignal: check mail for shutdown messages to coordinator
|
|
1189
|
+
if (triggers.onShutdownSignal) {
|
|
1190
|
+
const mailDb = join(config.project.root, ".overstory", "mail.db");
|
|
1191
|
+
const mailStore = createMailStore(mailDb);
|
|
1192
|
+
try {
|
|
1193
|
+
const unread = mailStore.getUnread("coordinator");
|
|
1194
|
+
const shutdownMsg = unread.find((m) => m.subject.toLowerCase().includes("shutdown"));
|
|
1195
|
+
result.triggers.onShutdownSignal.met = shutdownMsg !== undefined;
|
|
1196
|
+
result.triggers.onShutdownSignal.detail = shutdownMsg
|
|
1197
|
+
? `Shutdown signal from ${shutdownMsg.from}: ${shutdownMsg.subject}`
|
|
1198
|
+
: "No shutdown signal received";
|
|
1199
|
+
} finally {
|
|
1200
|
+
mailStore.close();
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Overall: complete only if ALL enabled triggers are met
|
|
1205
|
+
const enabledTriggers = Object.values(result.triggers).filter((t) => t.enabled);
|
|
1206
|
+
result.complete = enabledTriggers.length > 0 && enabledTriggers.every((t) => t.met);
|
|
1207
|
+
|
|
1208
|
+
if (opts.json) {
|
|
1209
|
+
jsonOutput("coordinator check-complete", result as unknown as Record<string, unknown>);
|
|
1210
|
+
} else {
|
|
1211
|
+
for (const [name, trigger] of Object.entries(result.triggers)) {
|
|
1212
|
+
const status = !trigger.enabled ? "disabled" : trigger.met ? "MET" : "NOT MET";
|
|
1213
|
+
process.stdout.write(` ${name}: ${status} — ${trigger.detail}\n`);
|
|
1214
|
+
}
|
|
1215
|
+
process.stdout.write(`\nComplete: ${result.complete ? "YES" : "NO"}\n`);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
return result;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1057
1221
|
/**
|
|
1058
1222
|
* Create the Commander command for `ov coordinator`.
|
|
1059
1223
|
*/
|
|
@@ -1150,6 +1314,14 @@ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
|
|
|
1150
1314
|
},
|
|
1151
1315
|
);
|
|
1152
1316
|
|
|
1317
|
+
cmd
|
|
1318
|
+
.command("check-complete")
|
|
1319
|
+
.description("Evaluate exit triggers and report completion status")
|
|
1320
|
+
.option("--json", "Output as JSON")
|
|
1321
|
+
.action(async (opts: { json?: boolean }) => {
|
|
1322
|
+
await checkComplete({ json: opts.json ?? false }, deps);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1153
1325
|
return cmd;
|
|
1154
1326
|
}
|
|
1155
1327
|
|