diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index c2ff2f58f3..10054edf07 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -379,16 +379,22 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const observeProcessOutcome = async (signal?: AbortSignal): Promise => { if (processOutcome !== undefined || child.pid <= 0) return processOutcome + const timeout = AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)) + const bound = signal === undefined ? timeout : AbortSignal.any([signal, timeout]) + const aborted = Promise.withResolvers() + const onObservationAbort = (): void => { aborted.resolve(undefined) } + bound.addEventListener('abort', onObservationAbort, { once: true }) + /* v8 ignore next -- closes the event-loop race between listener registration and the preceding derived-signal check. */ + if (bound.aborted) onObservationAbort() try { - const timeout = AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)) - const exited = await child.waitForExit( - signal === undefined ? timeout : AbortSignal.any([signal, timeout]), - ) - if (exited) return await processDone + return await Promise.race([processDone, aborted.promise]) } catch { // The active protocol failure remains authoritative when exit observation fails. + /* v8 ignore next -- a published child.done cannot reject; spawn rejection is consumed before publication. */ + return processOutcome + } finally { + bound.removeEventListener('abort', onObservationAbort) } - return processOutcome } // Startup rollback and the published handle share one process teardown. diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 3bbf75128a..bc493a35dd 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -959,7 +959,8 @@ describe('dsh-subagent-acp', () => { it('lets local cancellation interrupt prompt-failure process observation', async () => { const controller = new AbortController() - const observing = Promise.withResolvers() + const protocolEnded = Promise.withResolvers() + let boundedExitWaits = 0 const run = await startAcpRun(request('p', controller.signal), { command: process.execPath, args: [mockServer], @@ -968,9 +969,14 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' }, disposeEofGraceMs: 100, disposeGraceMs: 5000, - spawn: spec => tapBoundedExitWait(spawnSubprocess(spec), () => { observing.resolve(undefined) }), + spawn: (spec) => { + const child = spawnSubprocess(spec) + child.stdout?.once('end', () => { protocolEnded.resolve(undefined) }) + return tapBoundedExitWait(child, () => { boundedExitWaits += 1 }) + }, }) - await observing.promise + await protocolEnded.promise + await new Promise((resolve) => { setImmediate(resolve) }) controller.abort() await expect(Promise.race([ run.result, @@ -978,6 +984,7 @@ describe('dsh-subagent-acp', () => { setTimeout(() => { reject(new Error('cancellation waited for process observation')) }, 500) }), ])).resolves.toEqual({ output: [], stopReason: 'aborted' }) + expect(boundedExitWaits).toBe(0) await run.dispose() })