[Bug]: Runtime-mode, instance and worktree changes detach the Claude session and kill live subagents without the background-work check#16000

opened22d agobyOOscarsOrbit

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Start a Claude thread in full-access runtime mode.
  2. In one turn, start two pieces of background work and confirm both are progressing:
    • a background Bash command (run_in_background) that appends a counter line to a file every 5 s;
    • a background subagent (Agent tool, run_in_background) that runs 24 short timed steps (about 4 min) and appends one line per step to a second file.
  3. While both are still running, change the thread's runtime mode in the composer (Full access → Auto-accept edits) and send any message.
  4. Watch the thread, the two files, and the thread's orchestration events.

The worktree-change and provider-instance-switch paths go through the same detach (see Actual behavior); their trial results will follow in a comment.

Expected behavior

The same protection #14726 added for model and effort changes: while Claude still has background agents or commands running, a change that ends the Claude CLI process is refused or deferred with a clear, retryable message, and the background work is not lost. The guard's own comment gives the reason: "Background agents and shells run inside the CLI process, so a new selection would kill them and lose their results" (https://github.com/pingdotgg/t3code/blob/efecd3cf8bcec3d1891b5f5a27dc2f6d797c6448/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts#L6939-L6952).

Actual behavior

Sending the message with the new runtime mode ends the Claude session at once, with no refusal or warning, and the running subagent is killed. In one controlled trial (times UTC, from the thread's events and the two progress files):

  • 04:21:59.207 the message is sent: provider-session.detached, reason "Runtime mode changed."
  • 04:21:59.291 the subagent goes to failed, 84 ms later. Its progress file stopped at step 18 of 24 and its completion marker never appeared (checked at T+305 s).
  • 04:22:01.256 a new provider session attaches. The UI shows "Provider error — The provider event stream closed unexpectedly", the background agent as Failed mid-run, and the new session says the background command and subagent "were reported stopped when the previous session ended."

A note on background shells (not a confirmed T3 defect): in the same trial, our background Bash loop kept writing to its file after T3 and the new session reported it stopped (40 lines at the change, 64 at +120 s). We believe this is an artifact of our harness: the loop's timeout child is reparented and escapes the CLI teardown. We did not test a background command that stays inside the CLI process tree, so we report it only as an observation.

Source at efecd3cf:

Searched before filing (open and closed): "runtime mode background subagent", "worktree handoff background", "kills background agents", "BackgroundWorkBlocks", "provider-session.detached" (only #15695, unrelated). The closest report is #12694, closed by #14726, which guards model and effort replacement; this is the adjacent unguarded set (runtime mode, instance, worktree).

Suggested fix: apply the pending-background-work check before the session detach/restart, as #14726 does for query replacement (refuse with the same retryable message, or defer until the background roster is empty); on a detach, stop or reattach tracked background shells rather than reporting them stopped.

Impact

Major degradation or frequent failure

Version or commit

v0.0.46-nightly.20261004.2657 (efecd3cf8bcec3d1891b5f5a27dc2f6d797c6448)

Environment

Linux x64 container, self-hosted t3 server, Node 24.13.1, Claude Code 2.1.289, Codex 0.160.0; Claude via an API-compatible proxy (not involved)

Logs or stack traces

shell
104:18:33.352Z provider-session.attached status=ready
204:21:59.096Z subagent.updated status=running (last running update)
304:21:59.207Z provider-session.detached reason="Runtime mode changed."
404:21:59.291Z subagent.updated status=failed
504:22:01.256Z provider-session.attached status=ready (new session)
6subagent progress file: 18 of 24 steps; no completion marker at T+305 s
7background bash counter: 40 lines at the change, 64 at T+120 s, ran to its timeout (reported stopped)

Screenshots, recordings, or supporting files

No response

Workaround

Wait for background agents and commands to finish, or stop them, before changing the runtime mode, the provider instance or the worktree.

Synced from GitHub

Further results from the same controlled setup (v0.0.46-nightly.20261004.2657, efecd3cf), each trial with a background subagent and a background shell confirmed live before the change:

Change made mid-workValid trialsSession detachedBackground subagent
Provider instance switch, Claude → Codex (t3_thread_configure)3 / 3yes, reason "Provider or model selection changed."failed at the detach, 3 / 3
Provider instance switch, Claude → a second Claude instance on the same Claude home3 / 3yes, same reason stringfailed at the detach, 3 / 3
Worktree handoff (t3_worktree_handoff)3 / 3yes, reason "Workspace changed."failed at the detach, 3 / 3
Runtime mode change (reported above)1 / 1yes, reason "Runtime mode changed."failed
Control: model-only change on the same instance3 / 3nosurvived; the next turn was refused with the #14726 guard message
Control: effort-only change on the same instance3 / 3nosurvived; same guard message
Baseline: no change1 / 1nocompleted 24 of 24 steps

So the #14726 guard holds for model and effort changes, and every other change that ends the session (instance, worktree, runtime mode) kills live background subagents without a check. One more detail: the Claude-to-Claude instance switch took the same release path and reason as the switch to Codex, not a restart-and-resume path.

Note

Grok responding on behalf of Julius.

Triage

Thanks for the careful trial and the source pointers, @OscarsOrbit. Your event reasons and timings line up exactly with the code on current main (cf3e714b0).

What I found

This is the same process kill as #12694, on paths that #14726 didn't cover. It isn't a duplicate of #12694 or #15695.

#14726 only refuses a Claude query replacement (ClaudeBackgroundWorkBlocksQueryReplacementError in openQuery) while the live process still has background shells or running subagents. Model-only and effort-only changes stay on that path, which matches your control runs.

Runtime mode, provider instance, and worktree changes never reach that check. dispatchThreadMutation emits provider-session.detached and a detach effect without any pending-background test:

  • thread.runtime-mode.set detaches every live session whose capabilities have supportsRuntimeModeSwitchInSession: false. Claude sets that to false, and the reason is Runtime mode changed.
  • A provider-instance change plans restart_and_resume for another Claude instance, or create_with_handoff for a different driver. Both put the live Claude session in releaseProviderSessionIds, with the reason Provider or model selection changed.
  • A worktree change (thread.metadata.update with a new worktreePath) detaches every live session on the thread, with the reason Workspace changed.

Claude runs one thread per process, so the detach calls releaseEntry with manual_shutdown and closes the CLI. hasPendingBackgroundWork is only checked on idle release, so nothing holds the process here. The subagent going failed about 84 ms later, and the new session's "provider event stream closed unexpectedly", both follow from that close.

The background Bash loop that kept writing after being reported stopped is still just an observation. A timeout child can get reparented out of the CLI tree. The in-process subagent is the case the guard was built for.

Workaround: as you wrote, wait for background agents and commands to finish (or stop them) before changing runtime mode, provider instance, or worktree.

Likely fix area

  • dispatchThreadMutation in Orchestrator.ts, plus ProviderSwitchService.ts / ProviderSessionTransitionPolicy.ts. One option is to apply the #14726-style refusal before these detaches: if Claude still has background agents or commands, reject the change with the same retryable message and leave the process running. Another option is to defer the detach until the background work is gone.
  • ProviderSessionManager.ts (releaseEntry). A related question is how tracked background shells should be handled on a detach.

A maintainer will decide on the fix direction.

Synced from GitHub

I started a fix for this and stopped, because the direction is still open. These are the findings that bear on that choice. They come from reading main at e22c88043. I ran nothing, so each one is a code reading and not a test result.

F1. The worktree handoff detach is intended. The t3_worktree_handoff description tells the agent that the detach ends its turn and to call the tool last (tools.ts#L27). The service queues the continuation because "the detach that the metadata update schedules will terminate the calling session" (WorktreeMcpService.ts#L357-L362). For this trigger the choice is whether a handoff waits for, or refuses on, the calling agent's own background work.

F2. The source a check reads decides what it covers.

  • derivePendingBackgroundWork returns an empty list while any run is preparing, starting, or running (orchestrationV2PendingBackgroundWork.ts#L224-L232). A check built on it would miss a change made during a running turn. That includes every handoff, and a t3_thread_configure call an agent makes on its own thread.
  • The live Claude session's hasPendingBackgroundWork does not depend on run state (ClaudeAdapterV2.ts#L7477-L7503). It reads the same in-process roster and subagent state as the #14726 guard (#L6880-L6907). #16073 reports that guard refusing sends while the client showed no Stop, so a check on this source can produce the same stuck state.

F3. Three more detach sites have no check. A check placed only in dispatchThreadMutation leaves them open.

F4. Three callers would handle a refusal badly today.

F5. The runtime-mode detach is not Claude-only. It applies to every session whose adapter sets supportsRuntimeModeSwitchInSession: false (Orchestrator.ts#L3216-L3221). That is also the ACP base, Grok, Cursor, Pi, and OpenCodeAdapterV2. Each is a single-thread session, so the detach releases it (ProviderSessionManager.ts#L2009-L2018). I did not trace what background work each of those processes holds.

Written by Claude Fable 5.1 on behalf of ScottN-PV.

Synced from GitHub

Another case on the instance-switch path. It differs from the trials above in one way: the killed task never leaves running.

Setup. A Claude thread and two Claude provider instances that share one Claude home, so the switch resumes the same native thread. A Workflow tool task (task_type: local_workflow) was running in the background on instance A. The run that launched it had already completed. The next message went to instance B.

Timeline (UTC, from the provider event log):

  • 02:48:33 task_started for the workflow on instance A. Its run completed at 02:48:44, and the workflow kept going in the background.
  • 03:01:45.973 last task_progress for the task.
  • 03:01:47 a new run starts on instance B (instance switch, same native thread).
  • 03:01:49.141 query.close on instance A. 03:01:49.249 query.open with resume on instance B.
  • No task_notification for the task ever arrives.

State 11+ hours later:

  • The task's subagent turn item and its projection_subagents row are still running, last updated 03:01:45.974.
  • The latest run is completed, and the provider thread's background roster is empty. Instance A's old session row still reads ready.
  • The composer shows "Waiting on subagent …" with a Stop button, and the thread never settles on its own.
  • Two later TaskStop calls from the agent returned "No task found". Five later session detaches did not clear it.

In the trials above, the subagent went to failed 84 ms after the detach. I don't know why this one didn't. One difference is the task type: a Workflow, not an Agent call.

I saw this on a source build from 2026-10-09, which is older than main. The reading below is on current main (98a6246d0, rechecked on a01c48f86), where I found nothing that would end this item. I have not reproduced it on main.

Why nothing ends it:

  1. The send that moves the thread to another instance detaches the old session without a background-work check (Orchestrator.ts#L6511-L6538). This is the gap this issue describes.
  2. When the stream fails, the launching run's ingestion only logs it if the run is already finalized. So the fallback that closes the run's open subagents doesn't run (RunExecutionService.ts#L1324-L1333).
  3. The cleanup that runs when a query's stream ends only ends tool calls that subagents left open. It does not end the subagent item itself (ClaudeAdapterV2.ts#L5718-L5735).
  4. Each session keeps its own subagent registry (ClaudeAdapterV2.ts#L3470), so the session on instance B doesn't know the task. That is why TaskStop says "No task found". It is also why #16814 wouldn't reach it, since that PR only cleans up subagents from an earlier process in the same session.
  5. The pending background work list keeps any active subagent item. It doesn't check the item's provider instance or whether its session is still alive (orchestrationV2PendingBackgroundWork.ts#L186-L203), so the strip stays.

Stop on the strip does nothing, as in #16586. There are 515 newer turn items, so the task falls outside the 77-row snapshot that interruptThreadTurn reads. Stop then returns {sequence: 0} and sends no run.interrupt. #16867 keeps unfinished subagent items in that snapshot, so Stop would reach this one. That gives a manual way out, but the item stays running until someone presses Stop.

Workaround today: restart the app. Startup recovery marks the item cancelled.

Suggestion: whichever direction this issue takes (refuse or defer), if a switch still detaches the old session, that session's background items should end as interrupted once the detach goes through. Then a killed task doesn't stay running.

Note

Responses generated with Claude (model: Claude Opus 5.5)

Triage on main a12d6ed942: the runtime-mode change path still schedules provider-session detach in Orchestrator.ts. No runtime reproduction or fix attempted: changing this behavior crosses the automated triage hard stop for runtime modes. Leaving open for maintainer review.

Synced from GitHub

#23082 takes the runtime-mode path only. Changing the mode no longer detaches the session. The new mode applies when the next turn starts, and that restart is refused while background agents or commands are still running (the #14726 guard). Instance and worktree handoff are unchanged.

I also have a follow-up that applies a mode change to the turn that's already running (Claude, ACP/Grok, OpenCode 2.x, Muse), and makes the picker send the change right away. I'll open an Ideas discussion for it once the direction here is settled.