Skip to content

fix(server): emit terminal task rows for subagents that survive a stop sweep - #7585

Open
spiky02plateau wants to merge 1 commit into
pingdotgg:mainfrom
spiky02plateau:fix/reconcile-interrupted-tasks
Open

fix(server): emit terminal task rows for subagents that survive a stop sweep#7585
spiky02plateau wants to merge 1 commit into
pingdotgg:mainfrom
spiky02plateau:fix/reconcile-interrupted-tasks

Conversation

@spiky02plateau

@spiky02plateau spiky02plateau commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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: stopTask per task with a 3s timeout, and only an acknowledged stop got a synthesized terminal task.completed. Every failure mode (refusal, timeout, the id already gone) returned silently, and nothing downstream can recover: completeTurn and stopSessionInternal never sweep liveTaskIds, the client's coordinator cascade only reaches workflow members keyed by parentAgentId (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_tasks control request "the reconciliation source". It is not: per the installed SDK's types, Query.backgroundTasks backgrounds 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 settleLiveTasks drains liveTaskIds and emits one synthesized task.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), then interrupt(), then settle: once the interrupt lands the turn is dead and T3 has no remaining liveness signal for anything still marked live. stopSessionInternal settles before completeTurn (rows keep their turnId) and before session.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 what background_tasks actually 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 refused stopTask still yields a terminal stopped row after interrupt; a nested task keeps its owning-agent linkage on the settled row; stopSession emits terminal rows before session.exited.
  • tsgo --noEmit: no errors.
  • vp lint on 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 missing task_notification).

settleLiveTasks drains liveTaskIds and emits synthesized task.completed with status: "stopped" and existing task linkage. interruptTurn still best-efforts stopTask (same timeouts), then interrupt(), then always settles remaining live tasks instead of only synthesizing completion when stopTask succeeded. stopSessionInternal settles live tasks before completeTurn and session.exited.

Comments on background_tasks_changed are corrected: the SDK control is not a roster query. Tests cover refused stopTask, nested agentId on 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 interruptTurn and stopSession

  • Introduces settleLiveTasks in ClaudeAdapter.ts, which drains liveTaskIds and emits a synthesized task.completed event with status 'stopped' for each remaining live task.
  • interruptTurn now calls settleLiveTasks after attempting stopTask and invoking query.interrupt, guaranteeing terminal task rows even when individual stopTask calls reject or time out.
  • stopSessionInternal calls settleLiveTasks before completing the turn, ensuring task rows are emitted with the current turnId before session.exited.
  • Adds tests covering: stopTask rejection, nested task termination with agent linkage preserved, and correct ordering of task events before session.exited.

Macroscope summarized 287aa51.

…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.
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 658869f1-a1c9-44a4-a078-104f2a465540

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Aug 19, 2026
@macroscopeapp

macroscopeapp Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

MarcL01 added a commit to MarcL01/t3code that referenced this pull request Aug 25, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant