Merge ACP cancellation simplification into DSH SDK layer

This commit is contained in:
pku-xht
2026-08-21 07:10:24 +08:00
3 changed files with 37 additions and 10 deletions
+12 -10
View File
@@ -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.
@@ -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<InitializeResponse> {
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<InitializeResponse>(() => {})
}
return Promise.resolve({
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
@@ -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')