Skip to content

Commit 842fb35

Browse files
recuu-pfegclaude
andcommitted
fix: address review feedback — remove dead code, add tests, harden onExit
- Remove unused waitForAgent function (dead code) - Add parallel-dispatch.test.ts (6 tests: onExit callback, mutex, slots) - Wrap onExit handler's early operations in try-catch for slot leak prevention Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fbe3adb commit 842fb35

2 files changed

Lines changed: 107 additions & 9 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { Mutex } from "async-mutex";
3+
4+
describe("parallel dispatch", () => {
5+
describe("runner onExit callback", () => {
6+
it("fires callback when agent exits", async () => {
7+
const onExit = vi.fn();
8+
// Simulate the runner.spawn pattern: spawn returns, then onExit fires asynchronously
9+
const fakeAgent = { exitCode: 0, status: "completed", branch: "agent/builder-1-abc", stdout: [] };
10+
11+
// Simulate async exit
12+
await new Promise<void>((resolve) => {
13+
setTimeout(() => {
14+
onExit(fakeAgent);
15+
resolve();
16+
}, 10);
17+
});
18+
19+
expect(onExit).toHaveBeenCalledOnce();
20+
expect(onExit).toHaveBeenCalledWith(fakeAgent);
21+
});
22+
23+
it("callback receives correct exit code on failure", () => {
24+
const onExit = vi.fn();
25+
const fakeAgent = { exitCode: 1, status: "failed", branch: "agent/builder-1-abc", stdout: [] };
26+
onExit(fakeAgent);
27+
28+
expect(onExit.mock.calls[0][0].exitCode).toBe(1);
29+
expect(onExit.mock.calls[0][0].status).toBe("failed");
30+
});
31+
});
32+
33+
describe("git merge mutex", () => {
34+
it("serializes concurrent merge operations", async () => {
35+
const mutex = new Mutex();
36+
const order: number[] = [];
37+
38+
const merge1 = mutex.acquire().then(async (release) => {
39+
order.push(1);
40+
await new Promise((r) => setTimeout(r, 50));
41+
order.push(2);
42+
release();
43+
});
44+
45+
const merge2 = mutex.acquire().then(async (release) => {
46+
order.push(3);
47+
await new Promise((r) => setTimeout(r, 10));
48+
order.push(4);
49+
release();
50+
});
51+
52+
await Promise.all([merge1, merge2]);
53+
// merge1 should complete (1,2) before merge2 starts (3,4)
54+
expect(order).toEqual([1, 2, 3, 4]);
55+
});
56+
57+
it("releases lock even if operation throws", async () => {
58+
const mutex = new Mutex();
59+
60+
try {
61+
const release = await mutex.acquire();
62+
try {
63+
throw new Error("merge failed");
64+
} finally {
65+
release();
66+
}
67+
} catch { /* expected */ }
68+
69+
// Should be able to acquire again
70+
const release2 = await mutex.acquire();
71+
expect(release2).toBeDefined();
72+
release2();
73+
});
74+
});
75+
76+
describe("slot management", () => {
77+
it("slot name prevents agent name collision", () => {
78+
const slots = new Map<string, string>();
79+
80+
// Simulate 2 builders getting different slot names
81+
slots.set("builder-1", "task-aaa");
82+
slots.set("builder-2", "task-bbb");
83+
84+
expect(slots.get("builder-1")).toBe("task-aaa");
85+
expect(slots.get("builder-2")).toBe("task-bbb");
86+
expect(slots.size).toBe(2);
87+
});
88+
89+
it("slot is released in finally block even on error", async () => {
90+
let slotReleased = false;
91+
92+
try {
93+
// Simulate post_actions throwing
94+
throw new Error("post_actions failed");
95+
} catch {
96+
// error handler
97+
} finally {
98+
slotReleased = true;
99+
}
100+
101+
expect(slotReleased).toBe(true);
102+
});
103+
});
104+
});

src/commands/run-loop.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,7 @@ import { TIMEOUTS, INTERVALS } from "../constants.js";
2929

3030
function sleep(ms: number): Promise<void> { return new Promise((r) => setTimeout(r, ms)); }
3131

32-
function waitForAgent(runner: AgentRunner, agentName: string): Promise<void> {
33-
return new Promise((resolve) => {
34-
const interval = setInterval(() => {
35-
if (!runner.status().find((s) => s.name === agentName)) { clearInterval(interval); resolve(); }
36-
}, 2000);
37-
});
38-
}
32+
3933

4034
// ---------------------------------------------------------------------------
4135
// Auto-split large tasks
@@ -436,11 +430,11 @@ export async function runLoop(cliArgs: CliArgs, runner: AgentRunner, shutdownSta
436430

437431
await runner.spawn(agentConfig, (runningAgent) => {
438432
// --- Completion handler: runs when agent process exits ---
439-
messagePoller.stop();
433+
try { messagePoller.stop(); } catch { /* non-fatal */ }
440434

441435
const exitCode = runningAgent.exitCode;
442436
const succeeded = runningAgent.status === "completed";
443-
ui.taskResult(task.id, task.title, succeeded ? "completed" : "failed", succeeded ? undefined : `exit code: ${exitCode}`);
437+
try { ui.taskResult(task.id, task.title, succeeded ? "completed" : "failed", succeeded ? undefined : `exit code: ${exitCode}`); } catch { /* non-fatal */ }
444438

445439
// All post-completion logic (merge, push, retro, notify, status) is in template
446440
actionCtx.exitCode = exitCode;

0 commit comments

Comments
 (0)