fix: stop losing turn-lifecycle events (dangling sub-agents, stuck 'thinking') - #273
Merged
Conversation
The provider's `subagent_stop` event was the ONLY path that removed an entry from Session's #subagents map. That event originates in the Claude SDK's SubagentStop hook, which cannot fire once the query is aborted -- and interrupt, setModel, rotate and switchProvider all abort it via #abortController.abort(). Every such abort therefore orphaned each in-flight sub-agent permanently, with nothing anywhere reconciling the map afterwards. Three consequences: - subagentSnapshot (feeding /who and toInfo().subagents) only ever grew. The displayed sub-agent count climbed turn after turn and never came back down, which is how this was noticed. - #subagents and #subagentRegistrations grew unbounded for the lifetime of a long-lived session. - Worst: each orphan kept a LIVE delegated ZeroID token. Revocation is meant to ride the sub-agent's own stop; instead it fell through to deactivateSessionAgent's cascade at session destroy, so a dead sub-agent's credential stayed valid as long as the session lived. That quietly weakens the per-agent revocation guarantee. Adds #sweepStaleSubagents, which revokes and drops whatever remains. A Task sub-agent cannot outlive the turn that spawned it, so it runs at turn_done as the principled backstop, plus on the two abort paths that leave the session alive: interrupt() and #teardownProvider(). It is idempotent and returns immediately on an empty map (the common case), and double-revocation is free because deactivateSubagent no-ops on an id it already dropped -- so a trailing subagent_stop racing a sweep costs nothing. Not swept at destroy(): deactivateSessionAgent already cascades over the session's sub-agent keys there, and the Session object is discarded. Tests: 6 of the 8 new cases fail against the pre-fix code, covering the turn-end sweep, non-accumulation across turns, both abort paths, the identity revocation, and the info_update broadcast so clients see the count drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
The sweep added earlier reconciled dangling sub-agents but never asked why
they dangled. Root-causing that turned up two defects, one of which is the
"model answered but the spinner keeps spinning" report.
1. Mid-turn accounting counted pushes that produce no turn_done.
#pendingMidTurnCount exists so #consumeEvents can absorb the intermediate
turn_done the SDK emits when a mid-turn push starts a NEW query. It was
incremented for every mid-turn push, including "later" -- which merges
into the running turn and starts no query at all (ClaudeProvider:
shouldQuery = priority !== "later"). Any client sending with an explicit
"later" priority while the session was working therefore left the counter
one too high, and the consumer swallowed the turn's REAL terminal
turn_done as if it were an intermediate boundary: it re-asserted
"thinking" and continue'd, waiting for a turn_done that would never be
emitted.
That is the stuck spinner. It also stranded the turn's sub-agents and
tools, because the loop never exited and so neither the boundary flush nor
the consumer's finally ever ran. Recovery came only from the 5-minute
stall watchdog or the next send. Now only querying pushes are counted.
2. The provider's event channel dropped events silently.
ClaudeProvider.#emit was `try { queue?.push(e) } catch {}` -- three silent
losses in one expression: a null queue, a closed queue, and a full queue,
all indistinguishable and none logged. A lost subagent_stop left its
sub-agent dangling with a live delegated ZeroID token; a lost
tool_complete stranded the status at tool_running. Nothing said so.
#emit now reports the disposition, and id-keyed lifecycle events
(subagent_stop, tool_complete) are buffered and replayed into the next
turn instead of vanishing -- their handlers are idempotent on unknown ids,
so a late duplicate costs nothing. turn_done and streamed text are
deliberately not carried: a stale turn_done would end the next turn as it
began. The buffer is bounded and the log is deduped per loop generation.
There was also a fourth, invisible loss: after the consumer broke on
turn_done, the turn queue stayed OPEN and unread until the next turn
replaced it, so late events were accepted into a queue nobody would drain
-- push succeeded, so no error, no log. TurnRun.endTurn() (optional, called
once from the consumer's finally) closes it, converting that into the
observable carryover path.
3. Sweep moved to where reconciliation already lives.
#completeActiveTools has always run in the consumer's finally precisely
because provider events can be lost; sub-agents were simply never added to
the same backstop. The sweep now sits beside it, covering every exit path,
plus the mid-turn continuation branch -- which continue's without
dispatching to #handleProviderEvent and so was missed entirely by the
previous turn_done-case sweep. The abort-path sweeps stay as defence in
depth for interrupt/teardown, where hooks provably never fire.
Tests: 5 new cases on top of the existing 8. The three covering these
changes fail against the previous commit -- the stuck-spinner case by
timing out, which is the bug exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
akashjavelin
approved these changes
Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sub-agent counts climbed turn after turn and never came down, and sessions sat spinning at "thinking" after the model had visibly answered. Both trace to the same place: the provider's event channel loses turn-lifecycle events, silently.
This PR fixes the causes and keeps reconciliation as a backstop.
Cause 1 — mid-turn accounting counted pushes that produce no
turn_done#pendingMidTurnCountexists so#consumeEventscan absorb the intermediateturn_donethe SDK emits when a mid-turn push starts a new query. It was incremented for every mid-turn push — including"later", which merges into the running turn and starts no query at all (ClaudeProvider:shouldQuery = priority !== "later").session.sendexposespriorityon the wire, so any client sending"later"while the session was working left the counter one too high. The consumer then hit its terminalturn_done, treated it as an intermediate boundary, decremented, re-asserted"thinking"(session.ts:3379) andcontinued — waiting for aturn_donethat was never going to be emitted.That is the stuck spinner. It also stranded that turn's sub-agents and tools, because the loop never exited, so neither the boundary flush nor the consumer's
finallyever ran. Recovery came only from the 5-minute stall watchdog or the next send — which is why it looked intermittent.Now only querying pushes are counted.
Cause 2 —
#emitdropped events silentlyThree distinct losses in one expression — null queue, closed queue, full queue — indistinguishable and none logged. A lost
subagent_stopleft its sub-agent dangling with a live delegated ZeroID token; a losttool_completestranded status attool_running. Nothing recorded either, which is why this was invisible for so long.Now
#emitreports the disposition, and id-keyed lifecycle events (subagent_stop,tool_complete) are buffered and replayed into the next turn rather than vanishing — their handlers are idempotent on unknown ids, so a late duplicate costs nothing.turn_doneand streamed text are deliberately not carried: a staleturn_donewould end the next turn the instant it began. Buffer is bounded (100); the log is deduped per loop generation so a persistent fault reports once, not once per event.The fourth, invisible loss
After the consumer broke on
turn_done, the turn queue stayed open and unread until the next turn replaced it. Late events were accepted into a queue nobody would ever drain —pushsucceeded, so there was no error and no log to catch.TurnRun.endTurn()(optional, called once from the consumer'sfinally) closes it, converting that silent case into the observable carryover path.Cause 3 — the sweep was in the wrong place
#completeActiveTools()has always run in the consumer'sfinallyprecisely because provider events can be lost. Sub-agents were simply never added to that same backstop. The sweep now sits beside it, covering every exit path — cleanturn_done, error, stall recovery, ownership loss — plus the mid-turn continuation branch, whichcontinues without dispatching to#handleProviderEventand so was missed entirely by the earlierturn_done-case sweep.Abort-path sweeps (
interrupt(),#teardownProvider()) stay as defence in depth, since those abort the SDK query and the hooks provably never fire.What was leaking
All bounded by session destroy (
deactivateSessionAgentcascades), but real while a session lives:Session.#subagentsSession.#subagentRegistrationsPromise<void>per orphanAgentIdentityManager.#agentsidentityId,wimseUri,token,apiKey— a live credential for a dead sub-agentThe third is the one that matters: revocation is meant to ride the sub-agent's own stop, and instead waited for session teardown.
Verification
src/tests/session-subagent-lifecycle.test.ts— 13 cases. Verified by reverting the source and re-running:Fail against original
main(the sweep work): turn-end sweep · non-accumulation across turns · ZeroID revocation of an orphan · sweep on interrupt · sweep on provider teardown ·info_updatebroadcast.Fail against the first commit (the root-cause work): the
"later"mid-turn push no longer swallowing its terminalturn_done— which fails by timing out, the bug exactly · sub-agent reconciliation at an absorbed mid-turn boundary ·endTurn()closing the turn stream.Also pinned:
"now"pushes still correctly absorb their intermediate boundary (so the fix didn't just disable the mechanism), the normalsubagent_stoppath still works, orphans are revoked exactly once, and the carryover policy excludesturn_done/text.bun run typecheckclean (root + protocol + core)bun run lintclean, 347 filesbun test— 2203 pass, 19 skip, 0 fail across 152 filesKnown gaps
endTurn) is covered.turn_donestill hangs the consumer until the stall watchdog. Making the queue close a non-lossy terminal signal would need the provider to distinguish terminal from intermediateturn_done, duplicating a fragile state machine that currently lives only in Session — deliberately not attempted here. With Cause 1 fixed, the reachable path to that hang is closed; what remains is now logged rather than silent.active: booleanon each sub-agent entry is still vestigial (settrue, neverfalse). Untouched becausesubagentsis client-visible in the protocol (types.ts:220).🤖 Generated with Claude Code