@os-eco/overstory-cli 0.9.4 → 0.10.3
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 +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
2
3
|
import { mkdir } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import { ValidationError } from "../errors.ts";
|
|
5
|
+
import { MergeError, ValidationError } from "../errors.ts";
|
|
6
|
+
import { mergeLockPath } from "../merge/lock.ts";
|
|
5
7
|
import { createMergeQueue } from "../merge/queue.ts";
|
|
6
8
|
import {
|
|
7
9
|
cleanupTempDir,
|
|
@@ -597,6 +599,115 @@ merge:
|
|
|
597
599
|
});
|
|
598
600
|
});
|
|
599
601
|
|
|
602
|
+
describe("concurrent-merge lock", () => {
|
|
603
|
+
test("refuses to start when another live ov merge holds the lock for the same target", async () => {
|
|
604
|
+
await setupProject(repoDir, defaultBranch);
|
|
605
|
+
const branchName = "overstory/builder/bead-lock-1";
|
|
606
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
607
|
+
|
|
608
|
+
// Simulate another live ov merge by writing a lock file with this
|
|
609
|
+
// process's own PID — it is guaranteed alive, so the lock-holder check
|
|
610
|
+
// must treat it as an in-flight merge.
|
|
611
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
612
|
+
writeFileSync(
|
|
613
|
+
lockPath,
|
|
614
|
+
JSON.stringify({
|
|
615
|
+
pid: process.pid,
|
|
616
|
+
acquiredAt: new Date().toISOString(),
|
|
617
|
+
targetBranch: defaultBranch,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
622
|
+
process.stdout.write = (): boolean => true;
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
await mergeCommand({ branch: branchName });
|
|
626
|
+
expect(true).toBe(false); // should not reach here
|
|
627
|
+
} catch (err: unknown) {
|
|
628
|
+
expect(err).toBeInstanceOf(MergeError);
|
|
629
|
+
const msg = (err as MergeError).message;
|
|
630
|
+
expect(msg).toContain("Another ov merge is already running");
|
|
631
|
+
expect(msg).toContain(defaultBranch);
|
|
632
|
+
} finally {
|
|
633
|
+
process.stdout.write = originalWrite;
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("releases the lock after a successful merge so a second run can proceed", async () => {
|
|
638
|
+
await setupProject(repoDir, defaultBranch);
|
|
639
|
+
const branchName = "overstory/builder/bead-lock-2";
|
|
640
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
641
|
+
|
|
642
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
643
|
+
process.stdout.write = (): boolean => true;
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
await mergeCommand({ branch: branchName, json: true });
|
|
647
|
+
} finally {
|
|
648
|
+
process.stdout.write = originalWrite;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// After release, a fresh `ov merge --dry-run` for an unrelated branch
|
|
652
|
+
// (which doesn't take a lock) should still see no leftover lock file.
|
|
653
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
654
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("--dry-run does not acquire the lock", async () => {
|
|
658
|
+
await setupProject(repoDir, defaultBranch);
|
|
659
|
+
const branchName = "overstory/builder/bead-lock-dry";
|
|
660
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
661
|
+
|
|
662
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
663
|
+
process.stdout.write = (): boolean => true;
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
await mergeCommand({ branch: branchName, dryRun: true, json: true });
|
|
667
|
+
} finally {
|
|
668
|
+
process.stdout.write = originalWrite;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
672
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("steals a stale lock (dead PID) and proceeds", async () => {
|
|
676
|
+
await setupProject(repoDir, defaultBranch);
|
|
677
|
+
const branchName = "overstory/builder/bead-lock-stale";
|
|
678
|
+
await createCleanFeatureBranch(repoDir, branchName);
|
|
679
|
+
|
|
680
|
+
// PID 2147483647 (INT_MAX) is virtually never assigned — treat as dead.
|
|
681
|
+
const lockPath = mergeLockPath(join(repoDir, ".overstory"), defaultBranch);
|
|
682
|
+
writeFileSync(
|
|
683
|
+
lockPath,
|
|
684
|
+
JSON.stringify({
|
|
685
|
+
pid: 2147483647,
|
|
686
|
+
acquiredAt: new Date(Date.now() - 10 * 60_000).toISOString(),
|
|
687
|
+
targetBranch: defaultBranch,
|
|
688
|
+
}),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
let output = "";
|
|
692
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
693
|
+
process.stdout.write = (chunk: unknown): boolean => {
|
|
694
|
+
output += String(chunk);
|
|
695
|
+
return true;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
await mergeCommand({ branch: branchName, json: true });
|
|
700
|
+
} finally {
|
|
701
|
+
process.stdout.write = originalWrite;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const parsed = JSON.parse(output);
|
|
705
|
+
expect(parsed.success).toBe(true);
|
|
706
|
+
// Lock should be released after success.
|
|
707
|
+
expect(await Bun.file(lockPath).exists()).toBe(false);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
600
711
|
describe("conflict handling", () => {
|
|
601
712
|
test("content conflict auto-resolves: same file modified on both branches, verify incoming content wins", async () => {
|
|
602
713
|
await setupProject(repoDir, defaultBranch);
|
package/src/commands/merge.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { loadConfig } from "../config.ts";
|
|
|
16
16
|
import { MergeError, ValidationError } from "../errors.ts";
|
|
17
17
|
import { jsonOutput } from "../json.ts";
|
|
18
18
|
import { accent, printHint } from "../logging/color.ts";
|
|
19
|
+
import { acquireMergeLock } from "../merge/lock.ts";
|
|
19
20
|
import { createMergeQueue } from "../merge/queue.ts";
|
|
20
21
|
import { createMergeResolver } from "../merge/resolver.ts";
|
|
21
22
|
import { createMulchClient } from "../mulch/client.ts";
|
|
@@ -168,10 +169,22 @@ export async function mergeCommand(opts: MergeOptions): Promise<void> {
|
|
|
168
169
|
mulchClient,
|
|
169
170
|
});
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
// Dry-run is read-only with respect to git state — no lock needed. The
|
|
173
|
+
// real merge path acquires a lock on the target branch so a parallel
|
|
174
|
+
// `ov merge` can't observe in-progress conflict markers and report a
|
|
175
|
+
// false failure (seeds: overstory-9610).
|
|
176
|
+
const lock = dryRun
|
|
177
|
+
? null
|
|
178
|
+
: acquireMergeLock(join(config.project.root, ".overstory"), targetBranch);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (branchName) {
|
|
182
|
+
await handleBranch(branchName, queue, resolver, config, targetBranch, dryRun, json);
|
|
183
|
+
} else {
|
|
184
|
+
await handleAll(queue, resolver, config, targetBranch, dryRun, json);
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
lock?.release();
|
|
175
188
|
}
|
|
176
189
|
}
|
|
177
190
|
|
|
@@ -4,6 +4,8 @@ import { mkdtemp } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createEventStore } from "../events/store.ts";
|
|
7
|
+
import { removeConnection, setConnection } from "../runtimes/connections.ts";
|
|
8
|
+
import type { NudgeableConnection, NudgeResult } from "../runtimes/headless-connection.ts";
|
|
7
9
|
import { createSessionStore } from "../sessions/store.ts";
|
|
8
10
|
import { cleanupTempDir } from "../test-helpers.ts";
|
|
9
11
|
import type { AgentSession, StoredEvent } from "../types.ts";
|
|
@@ -63,6 +65,32 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
describe("paneAppearsBusy", () => {
|
|
69
|
+
test("flags Claude Code mid-think pane as busy", async () => {
|
|
70
|
+
const { paneAppearsBusy } = await import("./nudge.ts");
|
|
71
|
+
const sample = [
|
|
72
|
+
"╭───────────────────────────────────────────╮",
|
|
73
|
+
"│ ✻ Cooking… (5s · ↓ 0 tokens · esc to interrupt)",
|
|
74
|
+
"╰───────────────────────────────────────────╯",
|
|
75
|
+
" ⏵⏵ bypass permissions on (alt+m to cycle)",
|
|
76
|
+
].join("\n");
|
|
77
|
+
expect(paneAppearsBusy(sample)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("treats idle pane (no esc-to-interrupt) as not busy", async () => {
|
|
81
|
+
const { paneAppearsBusy } = await import("./nudge.ts");
|
|
82
|
+
const sample = [
|
|
83
|
+
"$ ❯ ls",
|
|
84
|
+
"src/",
|
|
85
|
+
"╭───────────────────────────────────────────╮",
|
|
86
|
+
"│ > _ │",
|
|
87
|
+
"╰───────────────────────────────────────────╯",
|
|
88
|
+
" ⏵⏵ bypass permissions on (alt+m to cycle)",
|
|
89
|
+
].join("\n");
|
|
90
|
+
expect(paneAppearsBusy(sample)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
66
94
|
describe("nudgeAgent", () => {
|
|
67
95
|
// We dynamically import to avoid circular issues
|
|
68
96
|
async function importNudge() {
|
|
@@ -77,20 +105,28 @@ describe("nudgeAgent", () => {
|
|
|
77
105
|
expect(result.reason).toContain("No active session");
|
|
78
106
|
});
|
|
79
107
|
|
|
80
|
-
test("returns error when agent is zombie", async () => {
|
|
81
|
-
writeSessionsToStore(tempDir, [
|
|
108
|
+
test("returns error with recovery hint when agent is zombie", async () => {
|
|
109
|
+
writeSessionsToStore(tempDir, [
|
|
110
|
+
makeSession({ state: "zombie", capability: "lead", taskId: "task-42" }),
|
|
111
|
+
]);
|
|
82
112
|
const { nudgeAgent } = await importNudge();
|
|
83
113
|
const result = await nudgeAgent(tempDir, "test-agent");
|
|
84
114
|
expect(result.delivered).toBe(false);
|
|
85
115
|
expect(result.reason).toContain("No active session");
|
|
116
|
+
expect(result.reason).toContain("state: zombie");
|
|
117
|
+
expect(result.reason).toContain("ov sling task-42 --capability lead --recover");
|
|
86
118
|
});
|
|
87
119
|
|
|
88
|
-
test("returns error when agent is completed", async () => {
|
|
89
|
-
writeSessionsToStore(tempDir, [
|
|
120
|
+
test("returns error with recovery hint when agent is completed", async () => {
|
|
121
|
+
writeSessionsToStore(tempDir, [
|
|
122
|
+
makeSession({ state: "completed", capability: "lead", taskId: "task-42" }),
|
|
123
|
+
]);
|
|
90
124
|
const { nudgeAgent } = await importNudge();
|
|
91
125
|
const result = await nudgeAgent(tempDir, "test-agent");
|
|
92
126
|
expect(result.delivered).toBe(false);
|
|
93
127
|
expect(result.reason).toContain("No active session");
|
|
128
|
+
expect(result.reason).toContain("state: completed");
|
|
129
|
+
expect(result.reason).toContain("ov sling task-42 --capability lead --recover");
|
|
94
130
|
});
|
|
95
131
|
|
|
96
132
|
test("finds active agent in working state", async () => {
|
|
@@ -230,3 +266,314 @@ describe("nudgeAgent", () => {
|
|
|
230
266
|
}
|
|
231
267
|
});
|
|
232
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("nudgeAgent with headless connection", () => {
|
|
271
|
+
async function importNudge() {
|
|
272
|
+
return await import("./nudge.ts");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Build a NudgeableConnection stub that records calls. */
|
|
276
|
+
function makeNudgeableConn(
|
|
277
|
+
result: NudgeResult = { status: "Queued" },
|
|
278
|
+
onNudge?: (text: string) => void,
|
|
279
|
+
): NudgeableConnection {
|
|
280
|
+
return {
|
|
281
|
+
sendPrompt: async () => {},
|
|
282
|
+
followUp: async () => {},
|
|
283
|
+
abort: async () => {},
|
|
284
|
+
getState: async () => ({ status: "idle" as const }),
|
|
285
|
+
close: () => {},
|
|
286
|
+
nudge: async (text: string) => {
|
|
287
|
+
if (onNudge) onNudge(text);
|
|
288
|
+
return result;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
afterEach(() => {
|
|
294
|
+
removeConnection("headless-test-agent");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("routes nudge through connection.nudge() when connection exists", async () => {
|
|
298
|
+
let capturedText = "";
|
|
299
|
+
setConnection(
|
|
300
|
+
"headless-test-agent",
|
|
301
|
+
makeNudgeableConn({ status: "Queued" }, (t) => {
|
|
302
|
+
capturedText = t;
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const { nudgeAgent } = await importNudge();
|
|
307
|
+
const result = await nudgeAgent(tempDir, "headless-test-agent", "ping", true);
|
|
308
|
+
|
|
309
|
+
expect(result.delivered).toBe(true);
|
|
310
|
+
expect(result.queued).toBe(true);
|
|
311
|
+
expect(capturedText).toBe("ping");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("queued=false when connection returns Delivered", async () => {
|
|
315
|
+
setConnection("headless-test-agent", makeNudgeableConn({ status: "Delivered" }));
|
|
316
|
+
|
|
317
|
+
const { nudgeAgent } = await importNudge();
|
|
318
|
+
const result = await nudgeAgent(tempDir, "headless-test-agent", "ping", true);
|
|
319
|
+
|
|
320
|
+
expect(result.delivered).toBe(true);
|
|
321
|
+
expect(result.queued).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("falls back to tmux path when connection has no nudge() method", async () => {
|
|
325
|
+
// Register a plain RuntimeConnection (no nudge method)
|
|
326
|
+
setConnection("headless-test-agent", {
|
|
327
|
+
sendPrompt: async () => {},
|
|
328
|
+
followUp: async () => {},
|
|
329
|
+
abort: async () => {},
|
|
330
|
+
getState: async () => ({ status: "idle" as const }),
|
|
331
|
+
close: () => {},
|
|
332
|
+
});
|
|
333
|
+
// Also add a sessions.db entry so resolveTargetSession can find something
|
|
334
|
+
writeSessionsToStore(tempDir, [makeSession({ agentName: "headless-test-agent" })]);
|
|
335
|
+
|
|
336
|
+
const { nudgeAgent } = await importNudge();
|
|
337
|
+
const result = await nudgeAgent(tempDir, "headless-test-agent");
|
|
338
|
+
// Falls through to tmux — tmux session not alive
|
|
339
|
+
expect(result.delivered).toBe(false);
|
|
340
|
+
expect(result.reason).toContain("not alive");
|
|
341
|
+
// No queued field when tmux path runs
|
|
342
|
+
expect(result.queued).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("debounce applies to headless nudges", async () => {
|
|
346
|
+
let nudgeCount = 0;
|
|
347
|
+
setConnection(
|
|
348
|
+
"headless-test-agent",
|
|
349
|
+
makeNudgeableConn({ status: "Queued" }, () => {
|
|
350
|
+
nudgeCount++;
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const { nudgeAgent } = await importNudge();
|
|
355
|
+
// First nudge — forced to bypass debounce and prime the state
|
|
356
|
+
await nudgeAgent(tempDir, "headless-test-agent", "first", true);
|
|
357
|
+
// Second nudge immediately — should be debounced (within 500ms window)
|
|
358
|
+
const second = await nudgeAgent(tempDir, "headless-test-agent", "second");
|
|
359
|
+
|
|
360
|
+
expect(nudgeCount).toBe(1);
|
|
361
|
+
expect(second.delivered).toBe(false);
|
|
362
|
+
expect(second.reason).toContain("Debounced");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("records nudge event for headless delivery", async () => {
|
|
366
|
+
setConnection("headless-test-agent", makeNudgeableConn({ status: "Queued" }));
|
|
367
|
+
|
|
368
|
+
const { nudgeAgent } = await importNudge();
|
|
369
|
+
await nudgeAgent(tempDir, "headless-test-agent", "event test", true);
|
|
370
|
+
|
|
371
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
372
|
+
const store = createEventStore(eventsDbPath);
|
|
373
|
+
try {
|
|
374
|
+
const events: StoredEvent[] = store.getTimeline({ since: "2000-01-01T00:00:00Z" });
|
|
375
|
+
const nudgeEvent = events.find((e) => {
|
|
376
|
+
if (!e.data) return false;
|
|
377
|
+
const data = JSON.parse(e.data) as Record<string, unknown>;
|
|
378
|
+
return data.type === "nudge";
|
|
379
|
+
});
|
|
380
|
+
expect(nudgeEvent).toBeDefined();
|
|
381
|
+
expect(nudgeEvent?.agentName).toBe("headless-test-agent");
|
|
382
|
+
const data = JSON.parse(nudgeEvent?.data ?? "{}") as Record<string, unknown>;
|
|
383
|
+
expect(data.delivered).toBe(true);
|
|
384
|
+
} finally {
|
|
385
|
+
store.close();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("tmux path: send-keys path invoked for agent with no connection", async () => {
|
|
390
|
+
writeSessionsToStore(tempDir, [makeSession({ state: "working" })]);
|
|
391
|
+
|
|
392
|
+
const { nudgeAgent } = await importNudge();
|
|
393
|
+
const result = await nudgeAgent(tempDir, "test-agent");
|
|
394
|
+
// No connection registered → tmux path → tmux session not alive
|
|
395
|
+
expect(result.delivered).toBe(false);
|
|
396
|
+
expect(result.reason).toContain("not alive");
|
|
397
|
+
expect(result.queued).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("nudgeAgent spawn-per-turn dispatch", () => {
|
|
402
|
+
async function importNudge() {
|
|
403
|
+
return await import("./nudge.ts");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function fakeLoadConfig(): typeof import("../config.ts").loadConfig {
|
|
407
|
+
return (async (root: string) => ({
|
|
408
|
+
project: { name: "test", root, canonicalBranch: "main" },
|
|
409
|
+
agents: {
|
|
410
|
+
baseDir: "agents",
|
|
411
|
+
manifestPath: ".overstory/agent-manifest.json",
|
|
412
|
+
maxConcurrent: 5,
|
|
413
|
+
maxSessionsPerRun: 0,
|
|
414
|
+
maxAgentsPerLead: 5,
|
|
415
|
+
maxDepth: 2,
|
|
416
|
+
staggerDelayMs: 0,
|
|
417
|
+
autoNudgeOnMail: false,
|
|
418
|
+
},
|
|
419
|
+
worktrees: { baseDir: ".overstory/worktrees" },
|
|
420
|
+
merge: { mode: "manual" },
|
|
421
|
+
mulch: { enabled: false, domains: {} },
|
|
422
|
+
canopy: { enabled: false },
|
|
423
|
+
taskTracker: { backend: "seeds", enabled: true },
|
|
424
|
+
watchdog: {
|
|
425
|
+
tier0Enabled: false,
|
|
426
|
+
tier0IntervalMs: 30_000,
|
|
427
|
+
tier1Enabled: false,
|
|
428
|
+
maxEscalationLevel: 3,
|
|
429
|
+
},
|
|
430
|
+
models: {},
|
|
431
|
+
logging: { verbose: false, redactSecrets: true },
|
|
432
|
+
runtime: { default: "claude" },
|
|
433
|
+
providers: {},
|
|
434
|
+
})) as unknown as typeof import("../config.ts").loadConfig;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function writeManifest(projectRoot: string): Promise<void> {
|
|
438
|
+
mkdirSync(join(projectRoot, ".overstory"), { recursive: true });
|
|
439
|
+
mkdirSync(join(projectRoot, "agents"), { recursive: true });
|
|
440
|
+
await Bun.write(join(projectRoot, "agents", "builder.md"), "# Builder\n");
|
|
441
|
+
await Bun.write(
|
|
442
|
+
join(projectRoot, ".overstory", "agent-manifest.json"),
|
|
443
|
+
JSON.stringify(
|
|
444
|
+
{
|
|
445
|
+
version: "1",
|
|
446
|
+
agents: {
|
|
447
|
+
builder: {
|
|
448
|
+
file: "builder.md",
|
|
449
|
+
model: "claude-sonnet",
|
|
450
|
+
tools: [],
|
|
451
|
+
capabilities: ["build"],
|
|
452
|
+
canSpawn: false,
|
|
453
|
+
constraints: [],
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
null,
|
|
458
|
+
"\t",
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
test("routes builder nudge through runTurn when flag is on", async () => {
|
|
464
|
+
writeSessionsToStore(tempDir, [makeSession({ state: "working", capability: "builder" })]);
|
|
465
|
+
await writeManifest(tempDir);
|
|
466
|
+
|
|
467
|
+
const calls: Array<{ userTurnNdjson: string }> = [];
|
|
468
|
+
const stubRunTurn = async (opts: import("../agents/turn-runner.ts").RunTurnOpts) => {
|
|
469
|
+
calls.push({ userTurnNdjson: opts.userTurnNdjson });
|
|
470
|
+
return {
|
|
471
|
+
exitCode: 0,
|
|
472
|
+
cleanResult: true,
|
|
473
|
+
newSessionId: null,
|
|
474
|
+
resumeMismatch: false,
|
|
475
|
+
terminalMailObserved: false,
|
|
476
|
+
durationMs: 1,
|
|
477
|
+
initialState: "booting" as const,
|
|
478
|
+
finalState: "working" as const,
|
|
479
|
+
stallAborted: false,
|
|
480
|
+
terminalMailMissing: false,
|
|
481
|
+
};
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const { nudgeAgent } = await importNudge();
|
|
485
|
+
const result = await nudgeAgent(tempDir, "test-agent", "please pivot", true, {
|
|
486
|
+
_loadConfig: fakeLoadConfig(),
|
|
487
|
+
_runTurnFn: stubRunTurn,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
expect(result.delivered).toBe(true);
|
|
491
|
+
expect(calls.length).toBe(1);
|
|
492
|
+
const parsed = JSON.parse(calls[0]?.userTurnNdjson?.trimEnd() ?? "");
|
|
493
|
+
expect(parsed.type).toBe("user");
|
|
494
|
+
expect(parsed.message.content[0].text).toBe("please pivot");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("task-scoped non-builder capability (scout) IS routed to spawn-per-turn", async () => {
|
|
498
|
+
writeSessionsToStore(tempDir, [
|
|
499
|
+
makeSession({ state: "working", capability: "scout", agentName: "scout-1" }),
|
|
500
|
+
]);
|
|
501
|
+
await writeManifest(tempDir);
|
|
502
|
+
|
|
503
|
+
let runTurnCalled = false;
|
|
504
|
+
const stubRunTurn = async () => {
|
|
505
|
+
runTurnCalled = true;
|
|
506
|
+
return {
|
|
507
|
+
exitCode: 0,
|
|
508
|
+
cleanResult: true,
|
|
509
|
+
newSessionId: null,
|
|
510
|
+
resumeMismatch: false,
|
|
511
|
+
terminalMailObserved: false,
|
|
512
|
+
durationMs: 1,
|
|
513
|
+
initialState: "booting" as const,
|
|
514
|
+
finalState: "working" as const,
|
|
515
|
+
stallAborted: false,
|
|
516
|
+
terminalMailMissing: false,
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const { nudgeAgent } = await importNudge();
|
|
521
|
+
await nudgeAgent(tempDir, "scout-1", "ping", true, {
|
|
522
|
+
_loadConfig: fakeLoadConfig(),
|
|
523
|
+
_runTurnFn: stubRunTurn,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
expect(runTurnCalled).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("persistent capability (coordinator) is NOT routed to spawn-per-turn", async () => {
|
|
530
|
+
writeSessionsToStore(tempDir, [
|
|
531
|
+
makeSession({ state: "working", capability: "coordinator", agentName: "coord-1" }),
|
|
532
|
+
]);
|
|
533
|
+
await writeManifest(tempDir);
|
|
534
|
+
|
|
535
|
+
let runTurnCalled = false;
|
|
536
|
+
const stubRunTurn = async () => {
|
|
537
|
+
runTurnCalled = true;
|
|
538
|
+
return {
|
|
539
|
+
exitCode: 0,
|
|
540
|
+
cleanResult: true,
|
|
541
|
+
newSessionId: null,
|
|
542
|
+
resumeMismatch: false,
|
|
543
|
+
terminalMailObserved: false,
|
|
544
|
+
durationMs: 1,
|
|
545
|
+
initialState: "booting" as const,
|
|
546
|
+
finalState: "working" as const,
|
|
547
|
+
stallAborted: false,
|
|
548
|
+
terminalMailMissing: false,
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const { nudgeAgent } = await importNudge();
|
|
553
|
+
await nudgeAgent(tempDir, "coord-1", "ping", true, {
|
|
554
|
+
_loadConfig: fakeLoadConfig(),
|
|
555
|
+
_runTurnFn: stubRunTurn,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(runTurnCalled).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("returns delivery error when runTurn throws", async () => {
|
|
562
|
+
writeSessionsToStore(tempDir, [makeSession({ state: "working", capability: "builder" })]);
|
|
563
|
+
await writeManifest(tempDir);
|
|
564
|
+
|
|
565
|
+
const stubRunTurn = async (): Promise<never> => {
|
|
566
|
+
throw new Error("simulated spawn failure");
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const { nudgeAgent } = await importNudge();
|
|
570
|
+
const result = await nudgeAgent(tempDir, "test-agent", "ping", true, {
|
|
571
|
+
_loadConfig: fakeLoadConfig(),
|
|
572
|
+
_runTurnFn: stubRunTurn,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(result.delivered).toBe(false);
|
|
576
|
+
expect(result.reason).toContain("Spawn-per-turn dispatch failed");
|
|
577
|
+
expect(result.reason).toContain("simulated spawn failure");
|
|
578
|
+
});
|
|
579
|
+
});
|