fix: report no running for a subagent's request that a cancel withdraws, for subagents RFD clients (#1337)

## Problem

For a subagents RFD client, a subagent's child session reports
`requires_action` while one of its permission or question requests is
open, and `running` again once its last open request ends (#1322,
`NativeSubagentRuntime.awaitingUser`). A request that ends because the
work was **stopped** is not an answer, but it still reported `running`.

On `main`, with Claude Code 2.1.296, a foreground child whose Bash
permission request is open, and a cancel on the parent:

| What stops the work | States the child reported |
| --- | --- |
| `session/cancel` (v1, and v2 on the child's own stream) | `running`,
`requires_action`, **`running`**, `idle cancelled` |
| `session/close` | `running`, `requires_action`, **`running`**, and
then nothing: a closed session reports no `idle` |

Claude Code's interrupt withdraws the request, so the SDK aborts the
signal of the `canUseTool` call, the adapter sends `$/cancel_request`,
and the request's `finally` reports `running`. The SDK's own report of
the child's end (`task_updated killed`) comes a few ms later (3 to 4 ms
in these runs), so the client shows a child that is being stopped as
working. After a `session/close` nothing corrects it.

## Cause

`awaitingUser` reported `running` whenever the last open request ended,
whatever ended it. A request ends in two ways, the user answers it or
Claude Code withdraws it, and a withdrawal alone does not say whether
the work was stopped. Claude Code also withdraws a request when
something else answers it, and then the child runs on. I ran one such
case, a `PermissionRequest` hook that allows the command after 3 s: the
child worked for 9 more seconds after the withdrawal, and `running` was
right. (The CLI also re-checks a pending ask when its permission context
changes, and withdraws it if that now allows the call. I did not manage
to trigger that: `session/set_mode` to `bypassPermissions` left the
request open in my run.) So "a withdrawn request reports no `running`"
is wrong: that child would sit on `requires_action` for the rest of its
run. Waiting for the child's next update instead does not help either:
in that run the child's first update after the withdrawal came 8 s
later, with the tool's result.

## Change

A request reports no `running` when it was **withdrawn after a cancel
that came while it was open**. Then Claude Code's interrupt withdrew it
as it stopped the child, and the SDK's events report the child `idle`.

- `NativeSubagentRuntime.parentCancelled` (called for `session/cancel`,
and by the teardown of `session/close`) counts the cancels in the RFD
form. It still reports nothing, so #2308 is unchanged: a child that
keeps running keeps its requests and its state.
- `awaitingUser` takes the request's `signal`, which the agent passes
for permission requests and AskUserQuestion (`withChildAwaitingUser`). A
request records the count when it opens. If its signal aborted and the
count changed, its end reports no `running`.
- Everything else is as before: a request that the user answers, also
after the cancel, reports `running`; a withdrawal without a cancel
reports `running`; two overlapping requests report once, when the last
one ends, by how that one ended.
- README: one clause in the work-state bullet of "Subagent sessions".

AIR is unchanged: `awaitingUser` only runs in the RFD form, and the AIR
golden files do not change.

## Evidence

Live runs against Claude Code 2.1.296 (Sonnet), driving `dist/index.js`
over stdio with a client that holds the child's permission request,
validating every update and request against the SDK's JSON schema (0
violations in all runs):

| Run | `main` | This PR |
| --- | --- | --- |
| v1, `session/cancel` | `running`, `requires_action`, `running`, `idle
cancelled` | `running`, `requires_action`, `idle cancelled` |
| v2, `session/cancel` (child's stream) | `running`, `requires_action`,
`running`, `idle cancelled` | `running`, `requires_action`, `idle
cancelled` |
| v1, `session/close` | `running`, `requires_action`, `running` |
`running`, `requires_action` |
| v1, hook allows after 3 s, no cancel | `running`, `requires_action`,
`running`, `idle end_turn` | the same |

Tests (all fail without the rule, as checked by removing it):

- `native-subagents.test.ts`: the cancel withdrawal (and the child's
`idle cancelled` after it); a withdrawal without a cancel (the hook
case, which a simpler "abort means stopped" rule fails); a request that
outlives a cancel and is answered later; a cancel that came before the
request opened; two overlapping requests, in each order.
- `acp-agent.test.ts`: through `canUseTool`, with `agent.cancel` and
`agent.closeSession` aborting the request as Claude Code does, and the
hook case.
- `acp-v2.test.ts`: the child's own `state_update` stream and the
parent's copy.

Removal checks, each run against the three test files: no rule (6 tests
fail), the cancel not counted (6), the agent not passing the signal (3:
agent cancel and close, v2), and the simple "any withdrawal" rule (3:
the hook cases).

`npm run check`, `npm run build` and `npm run test:run` pass (2935
tests; the scenario golden files do not change).

## Not covered

- **A stream end without a cancel.** If Claude Code exits by itself, the
SDK's cleanup aborts every request before the consumer reads the end of
the stream, and nothing at that moment tells it from a hook's
withdrawal. Live (`kill -9` of the CLI): `running`, then `idle`, 1 ms
apart. The session is dead right after. A rule for it would be a timing
one: report `running` for a withdrawal with no cancel one event-loop
turn later, so that `finishAll` goes first. I tried it (`await new
Promise((resolve) => setImmediate(resolve))` before the check in
`awaitingUser`'s `finally`): the `kill -9` run then reports `running`,
`requires_action`, `idle`, and the hook run still reports `running` 1 ms
after the withdrawal. I did not include it. It only works when the end
of the stream reaches `finishAll` in the same turn (a worker shutdown
publishes its failure notice first, for one), and it hides an ordering
assumption about the consumer. Your call.
- **Claude Code stopping a child on its own** (the model's TaskStop)
while its request is open: the same shape, no cancel.
- **The root session on v2** has the same flicker for its own request:
`requires_action`, `running`, `idle cancelled` (live, v2). It comes from
`syncAwaitingUser`, not from this runtime, and is not changed here.
- A background child that outlives a cancel and whose still-open request
a hook then withdraws would report no `running`. Claude Code stops
background children at an interrupt today, so it does not arise.

Related, and independent: a child's AskUserQuestion is not abandoned on
an abort when its client ignores `$/cancel_request` (#1338). The two PRs
touch the same call site and merge cleanly in either order (checked with
`git merge-tree`).
This commit is contained in:
Ben Brandt
2026-10-14 11:47:28 +02:00
committed by GitHub
parent 1fd217f3e7
commit c7425e4e04
6 changed files with 335 additions and 9 deletions

View File

@@ -42,7 +42,8 @@ Task subagent as its own child session, following the [subagents RFD](https://gi
- `subagent_update` on the parent announces the child before any of its traffic, with the
parent's `title` and `description` for it, and reports its work state: `running`,
`requires_action` while one of its requests is open, and `idle` when its work ends, with
`requires_action` while one of its requests is open (and `running` again when the last one ends,
unless a cancel withdrew it: the child is stopping then), and `idle` when its work ends, with
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
stop reason and the SDK's error text as its `error`. The child keeps its session for every
delegation to it.