@@ -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+
183218describe ( "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 ( / s e s s i o n c l o s e e x c e e d e d / ) ;
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 ( / s e s s i o n c l o s e e x c e e d e d / ) ;
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