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

@@ -71,7 +71,7 @@ export const Config: z<Config> = z.object({
class AcpProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
constructor(readonly name: string, private readonly config: Config) {}
constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {}
start(request: SubagentStartRequest) {
const spec: AcpRunSpec = {
@@ -80,11 +80,16 @@ class AcpProvider implements SubagentProvider {
cwd: this.config.cwd ?? process.cwd(),
permission: this.config.permission,
env: this.config.env,
onError: (error, stopReason) => {
// The seam forbids `result` rejecting, so a child-level failure is
// flattened to a stop reason — preserve it here rather than losing it.
this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`)
},
}
return startAcpRun(request, spec)
}
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new AcpProvider(config.providerName, config))
ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config))
}