Settle ACP cancel without the child's cooperation; preserve flattened errors (review feedback)

Two findings on the ACP backend:

Blocking: cancel() only sent session/cancel, so a child that ignores the notify
or wedges the prompt left result hung forever — the model-facing tool awaits
result before its finally disposes, so the parent cancellation hung and the
child stayed alive, violating the SubagentRun.cancel() contract (result settles
aborted). The result path now races the ACP drive against a cancelSettled
promise that requestCancel resolves, so result settles aborted the instant a
cancel is requested, regardless of the child. dispose() still kills+reaps the
process. New MOCK_IGNORE_CANCEL mock mode (receives cancel, never resolves the
prompt, never exits) drives a regression proven to hang without the race.

Nit: the drive-path catch was an empty broad catch that discarded the error
(AGENTS.md forbids). Because cancellation is now handled by the race arm, a
rejection reaching the catch is always a genuine child-level error — bind it,
flatten to error, and surface the original via a new AcpRunSpec.onError sink
that the provider wires to ctx.logger.warn, so a real fault is preserved.
This commit is contained in:
Tianyi Cui
2026-06-22 16:57:38 +08:00
parent 2086804b7e
commit 083af62785
4 changed files with 132 additions and 11 deletions

View File

@@ -8,6 +8,11 @@
* (`end_turn` default, or `max_tokens`/`refusal`/…).
* - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for
* a `session/cancel`), to exercise the client's cancel path.
* - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives
* `session/cancel` but NEVER resolves the pending prompt
* and never exits — a non-cooperative child. The backend's
* `result` must still settle `aborted` on its own and
* `dispose()` must still kill the process.
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
* before answering, to exercise the client's auto-answer.
* - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt`
@@ -63,6 +68,7 @@ const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
const THOUGHT = process.env.MOCK_THOUGHT === '1'
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
const READY_FILE = process.env.MOCK_READY_FILE
const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
@@ -150,6 +156,14 @@ function makeAgent(conn: AgentSideConnection): Agent {
// path: a transport failure after a cancel settles `aborted`).
process.exit(1)
}
if (IGNORE_CANCEL) {
// A NON-COOPERATIVE child: receive session/cancel but never resolve the
// pending prompt and never exit. The backend's `result` must still settle
// `aborted` on its own (the cancel-settle race), and `dispose()` must
// still kill the process — proving cancellation does not depend on the
// child cooperating.
return Promise.resolve()
}
resolveCancel?.('cancelled')
return Promise.resolve()
},

View File

@@ -385,6 +385,20 @@ describe('dsh-subagent-acp', () => {
})
it('resolves error (not reject) when the spawn command does not exist', async () => {
// Direct startAcpRun with NO onError sink — the catch must still flatten the
// spawn failure to `error` (the onError call is optional, covering the
// absent-sink branch).
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {} },
)
const result = await run.result
// The seam contract: a child-level failure resolves error, never rejects.
expect(result.stopReason).toBe('error')
await run.dispose()
})
it('resolves error via the provider (real load path) when the command does not exist', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(acp, {
@@ -396,11 +410,35 @@ describe('dsh-subagent-acp', () => {
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const result = await run.result
// The seam contract: a child-level failure resolves error, never rejects.
expect(result.stopReason).toBe('error')
await run.dispose()
})
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
// The seam forbids `result` rejecting, so a child-level failure is flattened
// to a stop reason — onError must still surface the original error so a real
// fault is logged, not swallowed. A nonexistent command triggers the spawn
// failure path; the spy records the error + the chosen stop reason.
const errors: { message: string; stopReason: string }[] = []
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
{
command: '/nonexistent/acp-agent-binary',
args: [],
cwd: process.cwd(),
permission: 'reject',
env: {},
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
},
)
const result = await run.result
expect(result.stopReason).toBe('error')
expect(errors).toHaveLength(1)
expect(errors[0]!.stopReason).toBe('error')
expect(errors[0]!.message.length).toBeGreaterThan(0)
await run.dispose()
})
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
// The child hangs, we cancel, and instead of answering the child exits hard
// — the pending prompt RPC rejects. With a cancel already requested, the
@@ -421,6 +459,31 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
// The contract: run.cancel() → result settles `aborted`. A child that hangs
// its prompt AND ignores session/cancel must not wedge the parent — the
// backend's own cancel-settle path resolves `aborted` without the child's
// cooperation, and dispose() still reaps the process.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
const ready = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
await waitForFile(ready)
run.cancel('test')
// Bound it: a regression (cancel only notifies the child, which ignores it)
// would hang result forever — fail loud instead of stalling the suite.
const result = await Promise.race([
run.result,
new Promise<never>((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }),
])
expect(result.stopReason).toBe('aborted')
await run.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('advertises no start-time capabilities (out-of-process child)', async () => {
const ctx = await setup()
const provider = ctx.subagents.getProvider('acp')!