diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 0a472a666b..11737fe78f 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -491,11 +491,13 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // A child closing its protocol stream can precede whole-tree exit // observation. Local cancellation does not need the discarded startup // classification; other failures use the configured process grace. - const startupOutcome = cancelledBeforeCleanup - ? processOutcome - : await observeProcessOutcome() - const failure = startupFailure(error, startupStage, child, startupOutcome) - if (cancelledBeforeCleanup) { + const startup = cancelledBeforeCleanup + ? { kind: 'cancelled' } as const + : { + kind: 'failed', + failure: startupFailure(error, startupStage, child, await observeProcessOutcome()), + } as const + if (startup.kind === 'cancelled') { // Local cancellation owns the startup outcome; only cleanup failure is // reported below when teardown itself rejects. } else { @@ -512,18 +514,18 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe category: processOutcome === undefined ? 'unknown' : 'process-exit', ...(processOutcome === undefined ? {} : { outcome: processOutcome }), }, cleanupError) - if (cancelledBeforeCleanup) { + if (startup.kind === 'cancelled') { throw new AggregateError([cleanupFailure], cleanupFailure.message) } throw new AggregateError( - [failure, cleanupFailure], - `${failure.message}; ${cleanupFailure.message}`, + [startup.failure, cleanupFailure], + `${startup.failure.message}; ${cleanupFailure.message}`, ) } - if (cancelledBeforeCleanup) { + if (startup.kind === 'cancelled') { throw new Error('subagent request was aborted before the ACP child started') } - throw failure + throw startup.failure } // The startup transaction validates the returned id before it can fulfill. // This assertion carries that cross-closure invariant into TypeScript. diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 90b335054c..c785ecedd2 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -23,6 +23,8 @@ * provider's fixed permission fact. * - `MOCK_CRASH_ON_INITIALIZE` — exit while the unpublished initialize * operation is active. + * - `MOCK_CLOSE_PROTOCOL_ON_INITIALIZE` — close stdout while keeping the + * process alive, producing initialize-stage transport. * - `MOCK_CLOSE_PROTOCOL_ON_PROMPT` — close stdout while keeping the process * alive, producing a prompt-stage transport failure. * - `MOCK_CRASH_AFTER_CHUNK` — exit after streaming the assistant chunk, so @@ -94,6 +96,7 @@ const IGNORE_PERMISSION_DECISION = process.env.MOCK_PERMISSION_IGNORE_DECISION = const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1' const THOUGHT = process.env.MOCK_THOUGHT === '1' const CRASH_ON_INITIALIZE = process.env.MOCK_CRASH_ON_INITIALIZE === '1' +const CLOSE_PROTOCOL_ON_INITIALIZE = process.env.MOCK_CLOSE_PROTOCOL_ON_INITIALIZE === '1' const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1' const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1' const CLOSE_PROTOCOL_ON_PROMPT = process.env.MOCK_CLOSE_PROTOCOL_ON_PROMPT === '1' @@ -118,6 +121,11 @@ function makeAgent(conn: AgentSideConnection): Agent { return { initialize(_params: InitializeRequest): Promise { if (CRASH_ON_INITIALIZE) process.exit(11) + if (CLOSE_PROTOCOL_ON_INITIALIZE) { + process.stdout.end() + setInterval(() => { /* keep the process alive after protocol EOF */ }, 1000) + return new Promise(() => {}) + } return Promise.resolve({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } }, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 9efc522759..b8b40a3750 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -573,6 +573,23 @@ describe('dsh-subagent-acp', () => { ) }) + it('reports initialize-stage transport when the child closes the protocol but stays alive', async () => { + const error = await startAcpRun(request(), { + command: process.execPath, + args: [mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { MOCK_CLOSE_PROTOCOL_ON_INITIALIZE: '1' }, + disposeEofGraceMs: 50, + disposeGraceMs: 50, + spawn: spawnSubprocess, + }).catch((cause: unknown) => cause) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe( + `subagent-acp: ${expectedFailure('stage: initialize; category: transport')}`, + ) + }) + it('reaps a child whose session/new response omits the session id', async () => { const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-')) const flushed = join(tmp, 'flushed')