@os-eco/overstory-cli 0.9.3 → 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 +49 -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 +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- 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/monitor.ts +2 -1
- 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 +85 -1
- package/src/commands/sling.ts +153 -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/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- 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 +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -118,6 +118,54 @@ describe("mailCommand", () => {
|
|
|
118
118
|
expect(output).toContain("Explore API");
|
|
119
119
|
expect(output).toContain("Total: 2 messages");
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
test("--type filters by message type", async () => {
|
|
123
|
+
// Add a typed message to the seeded inbox
|
|
124
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
125
|
+
const client = createMailClient(store);
|
|
126
|
+
client.send({
|
|
127
|
+
from: "lead-x",
|
|
128
|
+
to: "coordinator",
|
|
129
|
+
subject: "merge_ready: t1",
|
|
130
|
+
body: "ready to merge",
|
|
131
|
+
type: "merge_ready",
|
|
132
|
+
});
|
|
133
|
+
client.close();
|
|
134
|
+
|
|
135
|
+
await mailCommand(["list", "--type", "merge_ready"]);
|
|
136
|
+
expect(output).toContain("merge_ready: t1");
|
|
137
|
+
expect(output).not.toContain("Build task");
|
|
138
|
+
expect(output).not.toContain("Explore API");
|
|
139
|
+
expect(output).toContain("Total: 1 message");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("--type combined with --from filters by both", async () => {
|
|
143
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
144
|
+
const client = createMailClient(store);
|
|
145
|
+
client.send({
|
|
146
|
+
from: "lead-x",
|
|
147
|
+
to: "coordinator",
|
|
148
|
+
subject: "merge_ready: t1",
|
|
149
|
+
body: "ready",
|
|
150
|
+
type: "merge_ready",
|
|
151
|
+
});
|
|
152
|
+
client.send({
|
|
153
|
+
from: "lead-y",
|
|
154
|
+
to: "coordinator",
|
|
155
|
+
subject: "merge_ready: t2",
|
|
156
|
+
body: "ready",
|
|
157
|
+
type: "merge_ready",
|
|
158
|
+
});
|
|
159
|
+
client.close();
|
|
160
|
+
|
|
161
|
+
await mailCommand(["list", "--from", "lead-x", "--type", "merge_ready"]);
|
|
162
|
+
expect(output).toContain("merge_ready: t1");
|
|
163
|
+
expect(output).not.toContain("merge_ready: t2");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("--type rejects invalid type with ValidationError", async () => {
|
|
167
|
+
await expect(mailCommand(["list", "--type", "bogus"])).rejects.toThrow(/Invalid --type/);
|
|
168
|
+
});
|
|
121
169
|
});
|
|
122
170
|
|
|
123
171
|
describe("reply", () => {
|
|
@@ -1274,6 +1322,120 @@ describe("mailCommand", () => {
|
|
|
1274
1322
|
expect(stderrOutput).toBe("");
|
|
1275
1323
|
});
|
|
1276
1324
|
});
|
|
1325
|
+
|
|
1326
|
+
describe("terminal-state recipient rejection (overstory-f5be)", () => {
|
|
1327
|
+
async function seedRecipient(name: string, state: "working" | "completed" | "zombie") {
|
|
1328
|
+
const { createSessionStore } = await import("../sessions/store.ts");
|
|
1329
|
+
const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
|
|
1330
|
+
const sessionStore = createSessionStore(sessionsDbPath);
|
|
1331
|
+
sessionStore.upsert({
|
|
1332
|
+
id: `session-${name}`,
|
|
1333
|
+
agentName: name,
|
|
1334
|
+
capability: "builder",
|
|
1335
|
+
worktreePath: `/worktrees/${name}`,
|
|
1336
|
+
branchName: name,
|
|
1337
|
+
taskId: "bead-x",
|
|
1338
|
+
tmuxSession: `overstory-test-${name}`,
|
|
1339
|
+
state,
|
|
1340
|
+
pid: 99999,
|
|
1341
|
+
parentAgent: "orchestrator",
|
|
1342
|
+
depth: 1,
|
|
1343
|
+
runId: "run-001",
|
|
1344
|
+
startedAt: new Date().toISOString(),
|
|
1345
|
+
lastActivity: new Date().toISOString(),
|
|
1346
|
+
escalationLevel: 0,
|
|
1347
|
+
stalledSince: null,
|
|
1348
|
+
transcriptPath: null,
|
|
1349
|
+
});
|
|
1350
|
+
sessionStore.close();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
test("rejects send to recipient in completed state", async () => {
|
|
1354
|
+
await seedRecipient("dead-builder", "completed");
|
|
1355
|
+
|
|
1356
|
+
let caught: unknown;
|
|
1357
|
+
try {
|
|
1358
|
+
await mailCommand([
|
|
1359
|
+
"send",
|
|
1360
|
+
"--to",
|
|
1361
|
+
"dead-builder",
|
|
1362
|
+
"--subject",
|
|
1363
|
+
"Hello",
|
|
1364
|
+
"--body",
|
|
1365
|
+
"Are you there?",
|
|
1366
|
+
]);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
caught = err;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
expect(caught).toBeDefined();
|
|
1372
|
+
expect((caught as Error).name).toBe("MailError");
|
|
1373
|
+
expect((caught as Error).message).toContain("dead-builder");
|
|
1374
|
+
expect((caught as Error).message).toContain("completed");
|
|
1375
|
+
|
|
1376
|
+
// Confirm no message was inserted
|
|
1377
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
1378
|
+
const client = createMailClient(store);
|
|
1379
|
+
const messages = client.list({ to: "dead-builder" });
|
|
1380
|
+
expect(messages.length).toBe(0);
|
|
1381
|
+
client.close();
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
test("rejects send to recipient in zombie state", async () => {
|
|
1385
|
+
await seedRecipient("crashed-builder", "zombie");
|
|
1386
|
+
|
|
1387
|
+
let caught: unknown;
|
|
1388
|
+
try {
|
|
1389
|
+
await mailCommand([
|
|
1390
|
+
"send",
|
|
1391
|
+
"--to",
|
|
1392
|
+
"crashed-builder",
|
|
1393
|
+
"--subject",
|
|
1394
|
+
"Status?",
|
|
1395
|
+
"--body",
|
|
1396
|
+
"Ping",
|
|
1397
|
+
]);
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
caught = err;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
expect(caught).toBeDefined();
|
|
1403
|
+
expect((caught as Error).name).toBe("MailError");
|
|
1404
|
+
expect((caught as Error).message).toContain("zombie");
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("allows send when recipient has no session row (e.g. orchestrator)", async () => {
|
|
1408
|
+
// No session seeded for "orchestrator" — the existing beforeEach
|
|
1409
|
+
// only inserts mail rows, not session rows.
|
|
1410
|
+
await mailCommand([
|
|
1411
|
+
"send",
|
|
1412
|
+
"--to",
|
|
1413
|
+
"orchestrator",
|
|
1414
|
+
"--subject",
|
|
1415
|
+
"Hello",
|
|
1416
|
+
"--body",
|
|
1417
|
+
"Top-level role",
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
1421
|
+
const client = createMailClient(store);
|
|
1422
|
+
const messages = client.list({ to: "orchestrator" });
|
|
1423
|
+
expect(messages.length).toBeGreaterThanOrEqual(1);
|
|
1424
|
+
client.close();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("allows send to active (working) recipient", async () => {
|
|
1428
|
+
await seedRecipient("live-builder", "working");
|
|
1429
|
+
|
|
1430
|
+
await mailCommand(["send", "--to", "live-builder", "--subject", "Hello", "--body", "Active"]);
|
|
1431
|
+
|
|
1432
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
1433
|
+
const client = createMailClient(store);
|
|
1434
|
+
const messages = client.list({ to: "live-builder" });
|
|
1435
|
+
expect(messages.length).toBe(1);
|
|
1436
|
+
client.close();
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
1277
1439
|
});
|
|
1278
1440
|
|
|
1279
1441
|
describe("shouldAutoNudge", () => {
|
package/src/commands/mail.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { Command } from "commander";
|
|
10
|
+
import { Command, CommanderError } from "commander";
|
|
11
11
|
import { resolveProjectRoot } from "../config.ts";
|
|
12
|
-
import { ValidationError } from "../errors.ts";
|
|
12
|
+
import { MailError, ValidationError } from "../errors.ts";
|
|
13
13
|
import { createEventStore } from "../events/store.ts";
|
|
14
14
|
import { jsonOutput } from "../json.ts";
|
|
15
15
|
import { accent, printHint, printSuccess } from "../logging/color.ts";
|
|
@@ -253,6 +253,7 @@ interface ListOpts {
|
|
|
253
253
|
to?: string;
|
|
254
254
|
agent?: string;
|
|
255
255
|
unread?: boolean;
|
|
256
|
+
type?: string;
|
|
256
257
|
json?: boolean;
|
|
257
258
|
}
|
|
258
259
|
|
|
@@ -405,6 +406,30 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
|
|
|
405
406
|
}
|
|
406
407
|
}
|
|
407
408
|
|
|
409
|
+
// Reject sends to agents in a terminal state (completed/zombie).
|
|
410
|
+
// `installMailInjectors` reaps the per-agent dispatch loop the moment a
|
|
411
|
+
// session lands in a terminal state (serve.ts:378), so any mail addressed
|
|
412
|
+
// after that point would sit unread forever with no way to surface it.
|
|
413
|
+
// Sessions with no row at all (orchestrator, coordinator, operator roles)
|
|
414
|
+
// fall through — we only know about agents tracked in SessionStore.
|
|
415
|
+
// Group addresses already skip terminal agents via `getActive()`.
|
|
416
|
+
{
|
|
417
|
+
const overstoryDir = join(cwd, ".overstory");
|
|
418
|
+
const { store: sessionStore } = openSessionStore(overstoryDir);
|
|
419
|
+
try {
|
|
420
|
+
const recipient = sessionStore.getByName(to);
|
|
421
|
+
if (recipient && (recipient.state === "completed" || recipient.state === "zombie")) {
|
|
422
|
+
throw new MailError(
|
|
423
|
+
`Recipient "${to}" is in terminal state (${recipient.state}); message not sent. ` +
|
|
424
|
+
`The agent is no longer running, so this message would never be delivered.`,
|
|
425
|
+
{ agentName: to },
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
} finally {
|
|
429
|
+
sessionStore.close();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
408
433
|
// Single-recipient message (existing logic)
|
|
409
434
|
const client = openClient(cwd);
|
|
410
435
|
try {
|
|
@@ -603,9 +628,20 @@ function handleList(opts: ListOpts, cwd: string): void {
|
|
|
603
628
|
const unread = opts.unread ? true : undefined;
|
|
604
629
|
const json = opts.json ?? false;
|
|
605
630
|
|
|
631
|
+
let type: MailMessageType | undefined;
|
|
632
|
+
if (opts.type !== undefined) {
|
|
633
|
+
if (!MAIL_MESSAGE_TYPES.includes(opts.type as MailMessageType)) {
|
|
634
|
+
throw new ValidationError(
|
|
635
|
+
`Invalid --type "${opts.type}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
|
|
636
|
+
{ field: "type", value: opts.type },
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
type = opts.type as MailMessageType;
|
|
640
|
+
}
|
|
641
|
+
|
|
606
642
|
const client = openClient(cwd);
|
|
607
643
|
try {
|
|
608
|
-
const messages = client.list({ from, to, unread });
|
|
644
|
+
const messages = client.list({ from, to, unread, type });
|
|
609
645
|
|
|
610
646
|
if (json) {
|
|
611
647
|
jsonOutput("mail list", { messages });
|
|
@@ -732,8 +768,8 @@ export async function mailCommand(args: string[]): Promise<void> {
|
|
|
732
768
|
|
|
733
769
|
program
|
|
734
770
|
.command("check")
|
|
735
|
-
.description("Check inbox
|
|
736
|
-
.option("--agent <name>", "Agent name")
|
|
771
|
+
.description("Check inbox for one agent and mark unread as read (per-agent scope)")
|
|
772
|
+
.option("--agent <name>", "Agent name (default: orchestrator)")
|
|
737
773
|
.option("--inject", "Inject format for hook context")
|
|
738
774
|
.option("--json", "Output as JSON")
|
|
739
775
|
.option("--debounce <ms>", "Debounce interval in milliseconds")
|
|
@@ -744,11 +780,12 @@ export async function mailCommand(args: string[]): Promise<void> {
|
|
|
744
780
|
|
|
745
781
|
program
|
|
746
782
|
.command("list")
|
|
747
|
-
.description("List messages with filters")
|
|
783
|
+
.description("List messages with filters (system-wide unless --to/--agent given)")
|
|
748
784
|
.option("--from <name>", "Filter by sender")
|
|
749
|
-
.option("--to <name>", "Filter by recipient")
|
|
785
|
+
.option("--to <name>", "Filter by recipient (scopes to one agent)")
|
|
750
786
|
.option("--agent <name>", "Alias for --to (filter by recipient)")
|
|
751
|
-
.option("--unread", "Show only unread messages")
|
|
787
|
+
.option("--unread", "Show only unread messages (does NOT mark them read)")
|
|
788
|
+
.option("--type <type>", "Filter by message type")
|
|
752
789
|
.option("--json", "Output as JSON")
|
|
753
790
|
.exitOverride()
|
|
754
791
|
.action((opts: ListOpts) => {
|
|
@@ -789,5 +826,23 @@ export async function mailCommand(args: string[]): Promise<void> {
|
|
|
789
826
|
handlePurge(opts, root);
|
|
790
827
|
});
|
|
791
828
|
|
|
792
|
-
|
|
829
|
+
try {
|
|
830
|
+
await program.parseAsync(["node", "overstory-mail", ...args]);
|
|
831
|
+
} catch (err) {
|
|
832
|
+
// `exitOverride()` turns Commander's help paths into thrown
|
|
833
|
+
// CommanderErrors after the help text was already written to stdout.
|
|
834
|
+
// Swallow both the explicit `--help` path (commander.helpDisplayed,
|
|
835
|
+
// exitCode 0) and the missing-subcommand path (commander.help,
|
|
836
|
+
// exitCode 1) — the user got what they asked for.
|
|
837
|
+
if (
|
|
838
|
+
err instanceof CommanderError &&
|
|
839
|
+
(err.code === "commander.helpDisplayed" || err.code === "commander.help")
|
|
840
|
+
) {
|
|
841
|
+
if (err.exitCode !== 0) {
|
|
842
|
+
process.exitCode = err.exitCode;
|
|
843
|
+
}
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
throw err;
|
|
847
|
+
}
|
|
793
848
|
}
|
|
@@ -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
|
|
package/src/commands/monitor.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
createSession,
|
|
30
30
|
isSessionAlive,
|
|
31
31
|
killSession,
|
|
32
|
+
sanitizeTmuxName,
|
|
32
33
|
sendKeys,
|
|
33
34
|
TMUX_SOCKET,
|
|
34
35
|
} from "../worktree/tmux.ts";
|
|
@@ -42,7 +43,7 @@ const MONITOR_NAME = "monitor";
|
|
|
42
43
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
43
44
|
*/
|
|
44
45
|
function monitorTmuxSession(projectName: string): string {
|
|
45
|
-
return `overstory-${projectName}-${MONITOR_NAME}`;
|
|
46
|
+
return `overstory-${sanitizeTmuxName(projectName)}-${MONITOR_NAME}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|