Skip to content

Commit ea89f5c

Browse files
committed
test(subagent): helperize stub agent and cover leaky close
1 parent cfd033c commit ea89f5c

1 file changed

Lines changed: 137 additions & 94 deletions

File tree

‎src/subagent/run-shell-child-reap.test.ts‎

Lines changed: 137 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,17 @@ async function waitForChildExit(
5656
* spawns a real `sleep` descendant and pends (like shell collection output);
5757
* the abort listener kills the child first, mirroring runGuardedShell's
5858
* onAbort. `stream()` stays open until `releaseStream` — the session cannot
59-
* drain while the descendant is wedged.
59+
* drain while the descendant is wedged. `leakOnAbort` models a stub that
60+
* never kills: send-abort rejects and close() releases the stream, both
61+
* without killAll (and close stays non-wedged). The descendant is spawned
62+
* outside the shell guard, so production teardown never sees it — the run
63+
* must settle with the child still live, and the test reaps its own orphan
64+
* via the exposed killAll.
6065
*/
61-
function createShellChildAgent(opts?: { wedgeClose?: boolean }) {
66+
function createShellChildAgent(opts?: {
67+
wedgeClose?: boolean;
68+
leakOnAbort?: boolean;
69+
}) {
6270
const children: ChildProcess[] = [];
6371
let releaseStream: () => void = () => {
6472
// Replaced by the streamGate resolver below; the initializer only
@@ -84,6 +92,7 @@ function createShellChildAgent(opts?: { wedgeClose?: boolean }) {
8492
return {
8593
children,
8694
releaseStream,
95+
killAll,
8796
async send(_content: string, optsSend?: { signal?: AbortSignal }) {
8897
const child = spawn("sleep", ["60"], {
8998
stdio: "ignore",
@@ -93,7 +102,10 @@ function createShellChildAgent(opts?: { wedgeClose?: boolean }) {
93102
children.push(child);
94103
return await new Promise<never>((_resolve, reject) => {
95104
const abort = (reason: unknown): void => {
96-
killAll();
105+
// leakOnAbort rejects without killing: the descendant stays live
106+
// through teardown (production only reaps shell-guard-tracked
107+
// children, and this stub-spawned sleep is not one).
108+
if (opts?.leakOnAbort !== true) killAll();
97109
reject(reason instanceof Error ? reason : new Error("aborted"));
98110
};
99111
if (optsSend?.signal?.aborted === true) {
@@ -117,8 +129,9 @@ function createShellChildAgent(opts?: { wedgeClose?: boolean }) {
117129
deliver: () => undefined,
118130
close: async () => {
119131
// close() initiates the kill, but a wedged descendant holds the stream
120-
// open and the close itself never completes.
121-
killAll();
132+
// open and the close itself never completes. leakOnAbort instead
133+
// releases the stream without killing, and stays non-wedged.
134+
if (opts?.leakOnAbort !== true) killAll();
122135
releaseStream();
123136
if (opts?.wedgeClose === true) {
124137
await new Promise<never>(() => {
@@ -180,27 +193,39 @@ async function runWithShellChildAgent(
180193
return { runPromise, handles };
181194
}
182195

196+
/**
197+
* Drive a body with the live-tool-dispatch module mocked to hand out `agent`.
198+
* Extracts the mock boilerplate shared by every test below; the body is
199+
* invoked unchanged.
200+
*/
201+
async function runWithStubAgent<T>(
202+
agent: ReturnType<typeof createShellChildAgent>,
203+
body: () => Promise<T>,
204+
): Promise<T> {
205+
return withMockedModuleDuring(
206+
import.meta.resolve("../agent/live-tool-dispatch.js"),
207+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
208+
...real,
209+
createAgentWithLiveToolDispatch: async () =>
210+
agent as unknown as Awaited<
211+
ReturnType<typeof real.createAgentWithLiveToolDispatch>
212+
>,
213+
}),
214+
body,
215+
);
216+
}
217+
183218
describe("CL-7990 shell-child reap: sessions holding a live shell child settle", () => {
184219
test("interrupt settles a session parked behind a live shell child", async () => {
185220
const agent = createShellChildAgent();
186-
const outcome = await withMockedModuleDuring(
187-
import.meta.resolve("../agent/live-tool-dispatch.js"),
188-
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
189-
...real,
190-
createAgentWithLiveToolDispatch: async () =>
191-
agent as unknown as Awaited<
192-
ReturnType<typeof real.createAgentWithLiveToolDispatch>
193-
>,
194-
}),
195-
async () => {
196-
const { runPromise, handles } = await runWithShellChildAgent(agent, {
197-
persist: true,
198-
});
199-
handles.interrupt();
200-
const result = (await runPromise) as { interrupted?: boolean };
201-
return result;
202-
},
203-
);
221+
const outcome = await runWithStubAgent(agent, async () => {
222+
const { runPromise, handles } = await runWithShellChildAgent(agent, {
223+
persist: true,
224+
});
225+
handles.interrupt();
226+
const result = (await runPromise) as { interrupted?: boolean };
227+
return result;
228+
});
204229
expect(outcome.interrupted).toBe(true);
205230
for (const child of agent.children) {
206231
await waitForChildExit(child);
@@ -218,46 +243,36 @@ describe("CL-7990 shell-child reap: sessions holding a live shell child settle",
218243
retained: true,
219244
});
220245
const followupCalls: string[] = [];
221-
const outcome = await withMockedModuleDuring(
222-
import.meta.resolve("../agent/live-tool-dispatch.js"),
223-
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
224-
...real,
225-
createAgentWithLiveToolDispatch: async () =>
226-
agent as unknown as Awaited<
227-
ReturnType<typeof real.createAgentWithLiveToolDispatch>
228-
>,
229-
}),
230-
async () => {
231-
const { runPromise, handles } = await runWithShellChildAgent(agent, {
232-
persist: true,
233-
});
234-
// Wire the live run's handles like agent-fleet's onAgentReady.
235-
store.registerInterrupt(session.id, handles.interrupt);
236-
store.registerFollowup(session.id, async (message: string) => {
237-
followupCalls.push(message);
238-
return "steer reply";
239-
});
240-
store.markRunning(session.id);
241-
// The steer arrives while the worker is wedged: it stashes and fires
242-
// the interrupt; the bounded teardown must still settle the run.
243-
expect(
244-
store.sendInputOne(session.id, "steer mid-wedge", {
245-
interrupt: true,
246-
}),
247-
).toEqual({ ok: true, status: "interrupted" });
248-
const result = (await runPromise) as RunSubAgentResult;
249-
// agent-fleet settlement: attach the salvage so the stashed steer
250-
// launches instead of stranding on a phantom turn.
251-
store.attachReport(
252-
session.id,
253-
result.report,
254-
result.stopReason !== undefined
255-
? { stopReason: result.stopReason }
256-
: undefined,
257-
);
258-
return result;
259-
},
260-
);
246+
const outcome = await runWithStubAgent(agent, async () => {
247+
const { runPromise, handles } = await runWithShellChildAgent(agent, {
248+
persist: true,
249+
});
250+
// Wire the live run's handles like agent-fleet's onAgentReady.
251+
store.registerInterrupt(session.id, handles.interrupt);
252+
store.registerFollowup(session.id, async (message: string) => {
253+
followupCalls.push(message);
254+
return "steer reply";
255+
});
256+
store.markRunning(session.id);
257+
// The steer arrives while the worker is wedged: it stashes and fires
258+
// the interrupt; the bounded teardown must still settle the run.
259+
expect(
260+
store.sendInputOne(session.id, "steer mid-wedge", {
261+
interrupt: true,
262+
}),
263+
).toEqual({ ok: true, status: "interrupted" });
264+
const result = (await runPromise) as RunSubAgentResult;
265+
// agent-fleet settlement: attach the salvage so the stashed steer
266+
// launches instead of stranding on a phantom turn.
267+
store.attachReport(
268+
session.id,
269+
result.report,
270+
result.stopReason !== undefined
271+
? { stopReason: result.stopReason }
272+
: undefined,
273+
);
274+
return result;
275+
});
261276
expect(outcome.interrupted).toBe(true);
262277
// The stash launched synchronously from attachReport: the queued steer
263278
// was delivered, not dropped, and the shell child was reaped.
@@ -271,42 +286,70 @@ describe("CL-7990 shell-child reap: sessions holding a live shell child settle",
271286
"close settles a session whose stream is wedged by a shell descendant",
272287
async () => {
273288
const agent = createShellChildAgent({ wedgeClose: true });
274-
const outcome = await withMockedModuleDuring(
275-
import.meta.resolve("../agent/live-tool-dispatch.js"),
276-
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
277-
...real,
278-
createAgentWithLiveToolDispatch: async () =>
279-
agent as unknown as Awaited<
280-
ReturnType<typeof real.createAgentWithLiveToolDispatch>
281-
>,
282-
}),
283-
async () => {
284-
const { runPromise, handles } = await runWithShellChildAgent(agent, {
285-
persist: false,
286-
});
287-
const closeError = await handles.close(500).then(
288-
() => undefined,
289-
(err: unknown) => err,
290-
);
291-
expect(String(defined(closeError))).toMatch(/session close exceeded/);
292-
const settled = await Promise.race([
293-
runPromise.then(
294-
(result) => ({ state: "resolved" as const, result }),
295-
(error: unknown) => ({ state: "rejected" as const, error }),
296-
),
297-
new Promise<{ state: "timeout" }>((resolve) => {
298-
setTimeout(() => resolve({ state: "timeout" }), 45_000);
299-
}),
300-
]);
301-
expect(settled.state).not.toBe("timeout");
302-
return settled;
303-
},
304-
);
289+
const outcome = await runWithStubAgent(agent, async () => {
290+
const { runPromise, handles } = await runWithShellChildAgent(agent, {
291+
persist: false,
292+
});
293+
const closeError = await handles.close(500).then(
294+
() => undefined,
295+
(err: unknown) => err,
296+
);
297+
expect(String(defined(closeError))).toMatch(/session close exceeded/);
298+
const settled = await Promise.race([
299+
runPromise.then(
300+
(result) => ({ state: "resolved" as const, result }),
301+
(error: unknown) => ({ state: "rejected" as const, error }),
302+
),
303+
new Promise<{ state: "timeout" }>((resolve) => {
304+
setTimeout(() => resolve({ state: "timeout" }), 45_000);
305+
}),
306+
]);
307+
expect(settled.state).not.toBe("timeout");
308+
return settled;
309+
});
305310
expect(outcome.state).not.toBe("timeout");
306311
for (const child of agent.children) {
307312
await waitForChildExit(child);
308313
}
309314
},
310315
{ timeout: 60_000 },
311316
);
317+
318+
test(
319+
"interrupt with a leaky stub settles while the descendant is still live",
320+
async () => {
321+
// The stub never kills: send-abort rejects and close() releases the
322+
// stream, both without killAll (and close stays non-wedged). The run
323+
// must still settle — teardown never waits on a descendant it cannot
324+
// see. The stub-spawned sleep is outside the shell guard's tracked
325+
// set, so production cannot reap it: the test reaps its own orphan in
326+
// the finally, and waitForChildExit proves the collection.
327+
const agent = createShellChildAgent({ leakOnAbort: true });
328+
try {
329+
const outcome = await runWithStubAgent(agent, async () => {
330+
const { runPromise, handles } = await runWithShellChildAgent(agent, {
331+
persist: true,
332+
});
333+
handles.interrupt();
334+
const result = (await runPromise) as { interrupted?: boolean };
335+
return result;
336+
});
337+
expect(outcome.interrupted).toBe(true);
338+
// The wedged-descendant shape, for real this time: the child is
339+
// still live at settle time — no stub kill and no production reap
340+
// collected it behind the scenes.
341+
expect(agent.children.length).toBeGreaterThan(0);
342+
for (const child of agent.children) {
343+
expect(child.exitCode).toBeNull();
344+
expect(child.signalCode).toBeNull();
345+
}
346+
} finally {
347+
agent.killAll();
348+
for (const child of agent.children) {
349+
await waitForChildExit(child);
350+
}
351+
}
352+
},
353+
{ timeout: 60_000 },
354+
);
312355
});

0 commit comments

Comments
 (0)