fix(server): emit terminal task rows for subagents that survive a stop sweep - #7585
fix(server): emit terminal task rows for subagents that survive a stop sweep#7585spiky02plateau wants to merge 1 commit into
Conversation
…p sweep
Interrupting a turn left a spawned subagent's row stuck "running" forever.
The interrupt path stopped each live task, but on any failure mode - stopTask
rejecting, timing out, or the id already being gone - it returned without
emitting a terminal row, so only acknowledged stops ever produced one. Session
stop had the same hole: it emitted session.exited while leaving liveTaskIds
populated. Nothing else closes those rows out, so the panel counted up for the
life of the thread. Seen with a nested subagent, which no client-side cascade
covers either.
Both call sites now share settleLiveTasks, which drains liveTaskIds into
synthesized task.completed { status: "stopped" } rows carrying the usual
linkage. It runs after query.interrupt() resolves and before session.exited -
both points where nothing the session spawned can still be running. The SDK
offers no roster to reconcile against (Query.backgroundTasks backgrounds
tasks, it does not list them), so a task the CLI never confirmed is called
stopped rather than left spinning. The stale comment claiming the typed
background_tasks control request was the reconciliation source is corrected.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This bug fix modifies runtime event emission behavior in the ClaudeAdapter's task cleanup paths. While the change is well-scoped and well-tested, it affects core session lifecycle logic in a file the author hasn't previously contributed to, warranting human review. You can add or adjust custom eligibility rules. Learn more. |
…p sweep Interrupting a turn could leave a spawned subagent's row in the agents panel showing "running" forever. The interrupt path synthesized a terminal task.completed only when stopTask was acknowledged within its 3s timeout, so a timed-out stop or an id the SDK had already dropped bailed out silently, and nothing downstream swept liveTaskIds afterwards. A shared settleLiveTasks now drains liveTaskIds and emits one synthesized task.completed with status "stopped" per remaining id. interruptTurn settles after interrupt(), where the turn is dead and no liveness signal remains; stopSessionInternal settles before completeTurn so the rows keep their turnId, and before session.exited. Ported from pingdotgg#7585. Verified the defect was still present before porting: the acknowledged-only guard was live in interruptTurn, no settleLiveTasks existed, and neither completeTurn, handleStreamExit, nor stopSessionInternal drained liveTaskIds. Merged without conflicts. Co-authored-by: Tobi <155588579+spiky02plateau@users.noreply.github.com>
Interrupting a turn can leave a spawned subagent's row in the agents panel showing "running" forever: blue dot, duration counting up against the wall clock for the life of the thread. Observed with a nested agent (spawned from inside another subagent), but any task whose stop confirmation goes missing gets stranded the same way.
The interrupt path stops live tasks best-effort:
stopTaskper task with a 3s timeout, and only an acknowledged stop got a synthesized terminaltask.completed. Every failure mode (refusal, timeout, the id already gone) returned silently, and nothing downstream can recover:completeTurnandstopSessionInternalnever sweepliveTaskIds, the client's coordinator cascade only reaches workflow members keyed byparentAgentId(which nested agents never carry), and the client's dead-session sweep only fires when the session disconnects, which a turn interrupt does not cause.I checked whether the SDK offers a roster to reconcile against, since a comment in the adapter called the
background_taskscontrol request "the reconciliation source". It is not: per the installed SDK's types,Query.backgroundTasksbackgrounds in-flight tasks (the Ctrl+B equivalent) and returns a boolean;BackgroundTaskSummary[]only appears on stop-hook inputs. There is no on-demand liveness query.So the fix makes T3's own bookkeeping honest. A shared
settleLiveTasksdrainsliveTaskIdsand emits one synthesizedtask.completed { status: "stopped" }with the task's linkage per remaining id. The interrupt path's stop loop collapses to a pure best-effort sweep (same 3s/10s bounds), theninterrupt(), then settle: once the interrupt lands the turn is dead and T3 has no remaining liveness signal for anything still marked live.stopSessionInternalsettles beforecompleteTurn(rows keep theirturnId) and beforesession.exited, where it is unambiguous since the session process is going away. A turn that ends normally still leaves backgrounded tasks live on purpose. The stale comment now says whatbackground_tasksactually does.Left alone, for scope: the client's uncapped 1s duration interval (any future row missing its terminal event still counts up), and the client cascades that cannot reach nested agents. With the server now settling, those are defense-in-depth concerns.
Verification
In
apps/server:vp test run src/provider/Layers/ClaudeAdapter.test.ts: 73 passed (73). Three new tests: a refusedstopTaskstill yields a terminal stopped row after interrupt; a nested task keeps its owning-agent linkage on the settled row;stopSessionemits terminal rows beforesession.exited.tsgo --noEmit: no errors.vp linton both files: one pre-existing warning elsewhere in the file, nothing new.vp fmt --check: clean.Change made by Claude Opus via Claude Code.
Note
Medium Risk
Changes interrupt and session teardown paths in the Claude adapter; incorrect settlement could misreport agent state, but scope is bounded to live-task bookkeeping and is covered by new tests.
Overview
Fixes agent panel rows that stay running forever after Stop when the Claude SDK never confirms subagent shutdown (refused
stopTask, timeout, or missingtask_notification).settleLiveTasksdrainsliveTaskIdsand emits synthesizedtask.completedwithstatus: "stopped"and existing task linkage.interruptTurnstill best-effortsstopTask(same timeouts), theninterrupt(), then always settles remaining live tasks instead of only synthesizing completion whenstopTasksucceeded.stopSessionInternalsettles live tasks beforecompleteTurnandsession.exited.Comments on
background_tasks_changedare corrected: the SDK control is not a roster query. Tests cover refusedstopTask, nestedagentIdon settled rows, and shutdown event ordering.Reviewed by Cursor Bugbot for commit 287aa51. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Emit terminal task rows for subagents that survive a stop sweep in
interruptTurnandstopSessionsettleLiveTasksin ClaudeAdapter.ts, which drainsliveTaskIdsand emits a synthesizedtask.completedevent with status'stopped'for each remaining live task.interruptTurnnow callssettleLiveTasksafter attemptingstopTaskand invokingquery.interrupt, guaranteeing terminal task rows even when individualstopTaskcalls reject or time out.stopSessionInternalcallssettleLiveTasksbefore completing the turn, ensuring task rows are emitted with the currentturnIdbeforesession.exited.stopTaskrejection, nested task termination with agent linkage preserved, and correct ordering of task events beforesession.exited.Macroscope summarized 287aa51.