Cancelling during the prompt's initial whenIdle() wait could retain its
slot indefinitely. If a child delivered a result while the aborted root
was unwinding, Agent.whenIdle() followed the replacement driver and waited
for the newly autonomous summary. The earlier child-wait recheck could
not help because settlement had not yet reached that child wait.
This is an inherited latent issue: the pre-PR ACP bridge also awaited the
same replacement-following whenIdle() after cancellation. Existing
continuable spawn children could expose it; the unified asynchronous fork
default makes the ordering possible for fork delegations as well.
Give the existing InflightPrompt one cancellation-completion promise,
replacing the local cancellation wake-up and its listener. The private
admission AbortSignal is the cancellation flag instead of duplicated
boolean state. The exact root's emitted status identifies the interrupted
activity's retirement and synchronously adopts that output queue. Later
root work and later output cannot replace the first captured completion.
Record whether cancellation began at public idle. Maintenance has that
public status too, so its next running transition establishes release of
work behind maintenance; the ordinary whenIdle promise provides a
fallback when no transition occurs or plugin disposal detaches observers.
Both observations settle the same promise. Settlement races that promise
against initial idle and child waits, then waits only for the interrupted
activity/output on the cancellation path. No future work is re-cancelled,
child admission remains open, and session close keeps its separate full
quiescence and descendant teardown.
Add two deterministic real-Agent regressions. One gates aborted model
cleanup, previously queued image delivery, and the later autonomous
summary independently. The other gates cancelled maintenance cleanup and
the later summary. Both failed before this fix; both now wait for old work
and output, return cancelled while later work remains gated, and permit a
subsequent ACP prompt to execute another real local delegation. Existing
plugin-disposal tests also verify the idle fallback after observer cutoff.
Update the bilingual ACP semantics and generated event-consumer matrix.
Validation:
- Both focused regressions reproduced pending cancellation before the fix.
- pnpm exec vitest run packages/acp/acp/tests --coverage --coverage.include=packages/acp/acp/src/session.ts --coverage.include=packages/acp/acp/src/index.ts --coverage.reportsDirectory=/tmp/pr4394-acp-owned-cancel-89ec33e5788-consumer-v2
(150 passed; both changed source files have 100% statements, branches,
functions, and lines)
- pnpm exec tsc -b packages/acp/acp/tsconfig.json
- pnpm exec tsdown --config-loader native --env.DSH_BUILD_FACE host --filter '@deepseek-ai/dsh-acp'
- DSH_EXAMPLE_MODE=lib pnpm run test:e2e packages/subagent/subagent-dsh-sdk/tests/nested-delegation.e2e.ts -t '^external tasks.*acp'
(3 passed, 3 unselected)
- Focused lint, named translation pairing, generated graph verification,
and git diff --check passed.
Independent ACP rereview found no remaining issue, passed seven focused
turn/disposal cases, and verified a prepended idle listener can start a
new hanging root before ACP handles the old idle event without retaining
the cancelled prompt.