Repository navigation
Description
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
- Start a Claude thread in
full-accessruntime mode. - 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.
- a background Bash command (
- While both are still running, change the thread's runtime mode in the composer (Full access → Auto-accept edits) and send any message.
- 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" (
t3code/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Lines 6939 to 6952 in efecd3c
| // Background agents and shells run inside the CLI process, so a | |
| // new selection would kill them and lose their results. Refuse until | |
| // they finish or the user presses Stop, which closes the process. | |
| // Another native thread on this session is one the app thread has | |
| // left (Claude sessions serve one app thread), so it is replaced. | |
| if ( | |
| existing !== null && | |
| existing.nativeThreadId === nativeThreadId && | |
| !existing.stopping && | |
| (yield* liveProcessRunsBackgroundWork(existing)) | |
| ) { | |
| return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); | |
| } |
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 efecd3c:
- Runtime-mode, worktree and provider/instance changes go through
dispatchThreadMutation, which emitsprovider-session.detachedand a detach effect with no pending-background check (). Claude declarest3code/apps/server/src/orchestration-v2/Orchestrator.ts
Lines 3209 to 3290 in efecd3c
const detachSessionIds = new Set( command.type === "thread.archive" || command.type === "thread.settle" ? (providerContext?.providerSessions ?? []).map((session) => session.id) : command.type === "thread.metadata.update" && command.worktreePath !== undefined && command.worktreePath !== thread.worktreePath ? (providerContext?.providerSessions ?? []).map((session) => session.id) : command.type === "thread.runtime-mode.set" ? (providerContext?.providerSessions ?? []) .filter( (session) => !session.capabilities.sessions.supportsRuntimeModeSwitchInSession, ) .map((session) => session.id) : (providerSwitchPlan?.releaseProviderSessionIds ?? []), ); if (detachSessionIds.size > 0) { const liveSessions = (providerContext?.providerSessions ?? []).filter( (session) => detachSessionIds.has(session.id) && session.status !== "stopped" && session.status !== "error", ); yield* Effect.forEach( liveSessions, (session) => Effect.gen(function* () { yield* emit( events, command, )({ type: "provider-session.detached", threadId: command.threadId, driver: session.driver, providerInstanceId: session.providerInstanceId, occurredAt: now, payload: { providerSessionId: session.id, detachedAt: now, reason: command.type === "thread.archive" ? "Thread archived." : command.type === "thread.settle" ? "Thread settled." : command.type === "thread.metadata.update" ? "Workspace changed." : command.type === "thread.runtime-mode.set" ? "Runtime mode changed." : "Provider or model selection changed.", }, }); const pendingEffect = { id: `effect:${command.commandId}:provider-session.detach:${session.id}`, commandId: command.commandId, threadId: command.threadId, request: { type: "provider-session.detach", providerSessionId: session.id, detail: command.type === "thread.archive" ? "Thread archived." : command.type === "thread.settle" ? "Thread settled." : command.type === "thread.metadata.update" ? "Workspace changed." : command.type === "thread.runtime-mode.set" ? "Runtime mode changed." : "Provider or model selection changed.", // Terminal detaches revoke the thread's MCP credentials; other // detach reasons keep them so a re-attaching provider process // stays authorized. ...(command.type === "thread.archive" ? { revokeMcpCredential: true } : {}), }, } satisfies PendingOrchestrationEffectV2; yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); }), { concurrency: 1, discard: true }, ); } if (command.type === "thread.archive") { yield* Ref.update(effects, (existing) => [ ...existing, supportsRuntimeModeSwitchInSession: false().supportsRuntimeModeSwitchInSession: false, - An instance/runtime/workspace change returns
restart_and_resume(); the switch plan releases the session (t3code/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts
Lines 55 to 77 in efecd3c
const instanceChanged = current.modelSelection.instanceId !== target.modelSelection.instanceId; const runtimeChanged = current.runtimeMode !== target.runtimeMode; const workspaceChanged = current.workspace !== target.workspace; const selectionChanged = !modelSelectionsEqual(current.modelSelection, target.modelSelection); if (selectionChanged && !instanceChanged) { switch (input.selectionTransition?.type) { case "create_with_handoff": return { type: "create_with_handoff" }; case "reject": return { type: "reject", reason: input.selectionTransition.reason }; case undefined: return { type: "reject", reason: "The provider adapter did not classify the selection change.", }; case "apply_on_next_turn": case "restart_session": break; } } if (instanceChanged || runtimeChanged || workspaceChanged) { return { type: "restart_and_resume" }; } ).t3code/apps/server/src/orchestration-v2/ProviderSwitchService.ts
Lines 186 to 199 in efecd3c
const releaseProviderSessionIds = projection.providerSessions .filter((session) => { if (!isLiveProviderSession(session)) return false; if (transition.type === "restart_and_resume") { // Other live records may serve pooled or delegated bindings; // only the session being replaced is released. return session.id === currentSession?.id; } if (transition.type === "create_with_handoff") { return session.providerInstanceId !== targetModelSelection.instanceId; } return false; }) .map((session) => session.id); - Claude
detachcallsreleaseEntry(reason: "manual_shutdown"), closing the CLI process ().t3code/apps/server/src/orchestration-v2/ProviderSessionManager.ts
Lines 1912 to 2016 in efecd3c
detach: (input) => Effect.gen(function* () { const key = sessionKey(input.providerSessionId); const currentEntry = (yield* Ref.get(sessions)).get(key); let detachedProviderThreads: ReadonlyArray<OrchestrationV2ProviderThread> = []; if (currentEntry?.supportsMultipleProviderThreads === true) { const projection = yield* Effect.option( projectionStore.getThreadRecords(input.threadId, [ "providerThreads", "providerTurns", ]), ); if (Option.isSome(projection)) { const providerThreads = new Map( projection.value.providerThreads .filter((thread) => thread.providerSessionId === input.providerSessionId) .map((thread) => [thread.id, thread] as const), ); detachedProviderThreads = [...providerThreads.values()]; const activeTurns = projection.value.providerTurns.filter( (turn) => turn.status === "running" && providerThreads.has(turn.providerThreadId), ); yield* Effect.forEach( activeTurns, (turn) => currentEntry.exposedRuntime .interruptTurn({ providerThread: providerThreads.get(turn.providerThreadId)!, providerTurnId: turn.id, }) .pipe( Effect.catchCause((cause) => Effect.logWarning( "orchestration-v2.driver-session.detach-interrupt-failed", { providerSessionId: input.providerSessionId, threadId: input.threadId, providerTurnId: turn.id, cause, }, ), ), ), { concurrency: 1, discard: true }, ); } } const detached = yield* Ref.modify(sessions, (current) => { const entry = current.get(key); if (entry === undefined || !entry.attachedThreadIds.has(input.threadId)) { return [Option.none<LiveSessionEntry>(), current] as const; } const attachedThreadIds = new Set(entry.attachedThreadIds); attachedThreadIds.delete(input.threadId); const loadedProviderThreadKeyByThread = new Map( entry.loadedProviderThreadKeyByThread, ); loadedProviderThreadKeyByThread.delete(input.threadId); // For a plain (workspace-change) detach, the credential id stays // recorded: the thread may re-attach and reuse it, and // releaseEntry revokes it when the provider process finally goes // away. A terminal detach (archive/delete) prunes the record so // nothing vetoes the revocation below. const mcpCredentialIdByThread = input.revokeMcpCredential === true ? (() => { const pruned = new Map(entry.mcpCredentialIdByThread); pruned.delete(input.threadId); return pruned; })() : entry.mcpCredentialIdByThread; const updatedEntry = { ...entry, attachedThreadIds, loadedProviderThreadKeyByThread, mcpCredentialIdByThread, }; const updated = new Map(current); updated.set(key, updatedEntry); return [Option.some(updatedEntry), updated] as const; }); // Plain detaches deliberately do not revoke: a detached thread's // provider process may still be alive (shared multi-thread codex // session across a workspace handoff) and holds its MCP client's // credential for the thread it will re-attach with. Credentials // are revoked when the session entry is released (process gone) // or rotated on the next attach if they stopped resolving. // Terminal detaches (thread archived or deleted) revoke the // thread's credentials immediately, even on a retry where the // entry is already gone: there is no legitimate future re-attach, // and the token must not outlive the thread. if (input.revokeMcpCredential === true) { yield* clearMcpSession(input.threadId); } if (Option.isNone(detached)) { return; } if ( detached.value.attachedThreadIds.size === 0 && !detached.value.supportsMultipleProviderThreads ) { yield* releaseEntry({ providerSessionId: input.providerSessionId, reason: "manual_shutdown", ...(input.detail === undefined ? {} : { detail: input.detail }), hasPendingBackgroundWorkis consulted only on idle release (); the fix(server): Claude model changes no longer kill running background agents #14726 guard covers query replacement only.t3code/apps/server/src/orchestration-v2/ProviderSessionManager.ts
Lines 1005 to 1061 in efecd3c
const key = sessionKey(input.providerSessionId); const entry = current.get(key); if ( entry === undefined || entry.busyCount > 0 || entry.idleGeneration !== input.generation ) { return; } // Capture runtime identity before yielding: a replacement session // can reuse the same providerSessionId while this fiber is parked. const probedRuntime = entry.runtime; const hasPendingWork = probedRuntime.hasPendingBackgroundWork === undefined ? false : yield* probedRuntime.hasPendingBackgroundWork.pipe( Effect.catchCause(() => Effect.succeed(false)), ); if (hasPendingWork) { const now = yield* Clock.currentTimeMillis; const pinnedSinceMs = entry.pinnedSinceMs ?? now; if (now - pinnedSinceMs < maxIdlePinMs) { const shouldContinuePin = yield* Ref.modify(sessions, (latest) => { const latestEntry = latest.get(key); if ( latestEntry === undefined || latestEntry.busyCount > 0 || latestEntry.idleGeneration !== input.generation || latestEntry.runtime !== probedRuntime ) { return [false, latest] as const; } const updated = new Map(latest); updated.set(key, { ...latestEntry, pinnedSinceMs }); return [true, updated] as const; }); if (!shouldContinuePin) { // Generation or runtime advanced while we probed pending work; // the current owner of the entry owns idle release. return; } yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", { providerSessionId: input.providerSessionId, pinnedForMs: now - pinnedSinceMs, }); // Re-check on this fiber after another idle window. Do not call // scheduleIdleReleaseInternal: that cancels entry.idleFiber, which // is this fiber, and can self-deadlock on Fiber.interrupt. yield* Effect.sleep(Duration.millis(idleTimeoutMs)); return yield* releaseIfStillIdle(input); } yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", { providerSessionId: input.providerSessionId, pinnedForMs: now - pinnedSinceMs, }); } // hasPendingBackgroundWork yields to the adapter, so the idle
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 (efecd3c)
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
04:18:33.352Z provider-session.attached status=ready
04:21:59.096Z subagent.updated status=running (last running update)
04:21:59.207Z provider-session.detached reason="Runtime mode changed."
04:21:59.291Z subagent.updated status=failed
04:22:01.256Z provider-session.attached status=ready (new session)
subagent progress file: 18 of 24 steps; no completion marker at T+305 s
background 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.
Activity
OscarsOrbit commentedon Oct 5, 2026
Further results from the same controlled setup (v0.0.46-nightly.20261004.2657, efecd3c), each trial with a background subagent and a background shell confirmed live before the change:
t3_thread_configure)t3_worktree_handoff)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.
juliusmarminge commentedon Oct 5, 2026
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 (
ClaudeBackgroundWorkBlocksQueryReplacementErrorinopenQuery) 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.
dispatchThreadMutationemitsprovider-session.detachedand a detach effect without any pending-background test:thread.runtime-mode.setdetaches every live session whose capabilities havesupportsRuntimeModeSwitchInSession: false. Claude sets that to false, and the reason isRuntime mode changed.restart_and_resumefor another Claude instance, orcreate_with_handofffor a different driver. Both put the live Claude session inreleaseProviderSessionIds, with the reasonProvider or model selection changed.thread.metadata.updatewith a newworktreePath) detaches every live session on the thread, with the reasonWorkspace changed.Claude runs one thread per process, so the detach calls
releaseEntrywithmanual_shutdownand closes the CLI.hasPendingBackgroundWorkis only checked on idle release, so nothing holds the process here. The subagent goingfailedabout 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
timeoutchild 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
dispatchThreadMutationinOrchestrator.ts, plusProviderSwitchService.ts/ProviderSessionTransitionPolicy.ts. One option is to apply the fix(server): Claude model changes no longer kill running background agents #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.
ScottN-PV commentedon Oct 5, 2026
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
mainate22c88043. 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_handoffdescription 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.
derivePendingBackgroundWorkreturns 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 at3_thread_configurecall an agent makes on its own thread.hasPendingBackgroundWorkdoes not depend on run state (ClaudeAdapterV2.ts#L7477-L7503). It reads the same in-process roster and subagent state as the fix(server): Claude model changes no longer kill running background agents #14726 guard (#L6880-L6907). [Bug]: Claude is still running background agents or commands, and this model or setting change would end them. Wait for them to finish, or press Stop, then send the message again. #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
dispatchThreadMutationleaves them open.stopThreadSessionwithout awaiting it and separately updates the worktree.stopThreadSessionsendsprovider-session.detachfor every session (BranchToolbarBranchSelector.tsx#L170-L184,commands.ts#L897-L913). The handler only checks that the session belongs to the thread (Orchestrator.ts#L3301-L3352).Orchestrator.ts#L1659-L1664,#L1796-L1805).Orchestrator.ts#L6282-L6309).F4. Three callers would handle a refusal badly today.
use-thread-outbox-drain.ts#L202-L204,#L810-L823,thread-outbox-model.ts#L297-L310). A refused command leaves a rejected receipt, and the same id then returnsOrchestratorCommandPreviouslyRejectedError(Orchestrator.ts#L9710-L9717,#L9766-L9786). After one refusal, the queued message would keep failing after the background work ends.t3_thread_configuremaps every dispatch failure to "The operation could not be completed." (handlers.ts#L187-L194,threadAccess.ts#L18-L22).t3_worktree_handoffreports the error's.message(WorktreeMcpService.ts#L337-L341).OrchestratorDispatchError.messageis generic, so a refusal that carries its text incausewould lose it (Orchestrator.ts#L131-L133).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, andOpenCodeAdapterV2. 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.
kaovilai commentedon Oct 13, 2026
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):
task_startedfor the workflow on instance A. Its run completed at 02:48:44, and the workflow kept going in the background.task_progressfor the task.query.closeon instance A. 03:01:49.249query.openwithresumeon instance B.task_notificationfor the task ever arrives.State 11+ hours later:
subagentturn item and itsprojection_subagentsrow are stillrunning, last updated 03:01:45.974.completed, and the provider thread's background roster is empty. Instance A's old session row still readsready.TaskStopcalls from the agent returned "No task found". Five later session detaches did not clear it.In the trials above, the subagent went to
failed84 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 ona01c48f86), where I found nothing that would end this item. I have not reproduced it on main.Why nothing ends it:
TaskStopsays "No task found". It is also why fix(server): Claude subagents from a closed process no longer stay running #16814 wouldn't reach it, since that PR only cleans up subagents from an earlier process in the same session.subagentitem. 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
interruptThreadTurnreads. Stop then returns{sequence: 0}and sends norun.interrupt. #16867 keeps unfinishedsubagentitems in that snapshot, so Stop would reach this one. That gives a manual way out, but the item staysrunninguntil 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
interruptedonce the detach goes through. Then a killed task doesn't stayrunning.Note
Responses generated with Claude (model: Claude Opus 5.5)
feat(web): move a worktree thread into any of the project's worktrees
juliusmarminge commentedon Oct 22, 2026
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.
mackinleysmith commentedon Oct 23, 2026
#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.
jamesdchen commentedon Nov 2, 2026
Another case on the runtime-mode path, on the v0.1.6 Windows desktop app (released 2026-10-31). PR #23082 is still open and this build doesn't have it. This one adds a mid-tool-call failure and a comparison of which subagents survive.
Setup. A long Claude thread (Opus 5.5) acting as coordinator, with an active run that had just started a native
Agentsubagent, plus other native background subagents, and fivedelegate_taskchildren running in their own sessions. The thread's runtime mode then changed fromautotofull-accessfrom the client, followed by a new message.What happened (server trace and thread activity, all 2026-11-02 UTC).
EnvironmentCommands.setThreadRuntimeModeProviderSessionManager.detach(20 ms later, exitSuccess)failed: "The provider event stream closed unexpectedly. Retry the turn; if it keeps failing, check the provider and server logs."Agentsubagent started in that run ends asfailedWhat the agent sees afterwards.
stopped: "didn't finish before the previous session ended". Its transcript is kept, but its work stops.What survived. All five
delegate_taskchildren kept running, because they have their own sessions. So the loss only affects nativeAgentwork in the session whose mode changed, which is the same split the guard's comment describes.Expected. As #16000 asks: refuse or defer while background work is live, or apply the mode without ending the process, as #23082 proposes. At minimum, say that the mode change ended the session instead of showing a generic stream error.
marcuscastelo commentedon Nov 6, 2026
Another observation on the same detach, on v0.1.8-nightly.20261106.4262 (macOS, Claude provider), shared as data in case it helps. We are not sure whether parts of it are intended: the code comment above the detach reads "Archive and settle stop idle background work", which may be deliberate for settle and archive.
What we saw in practice. An external client moved several idle Claude threads to another Claude instance with
thread.model-selection.set, once each thread's turn had ended. Four threads had a background Bash command in flight, registered witht3_background_wait. In each one the command ended with<status>killed</status>a few seconds after the switch. The thread did not receive the result and stayed idle until someone wrote to it.Minimal reproduction (v0.1.8-nightly.20261106.4262). Two throwaway threads, each running
sleep 900; echo …in the background witht3_background_waitarmed, with the task visible in the shell snapshot'spendingBackgroundTasksbefore the action:thread.model-selection.setto another Claude instance<status>killed</status>within seconds; the new session got the killed notice through the handoff and started the command againthread.settle(through the MCPt3_thread_organize)<status>killed</status>; thread settled withpendingBackgroundTasksemptyWhere it differs from message dispatch. When the same instance switch comes with a message, the server refuses it with "The previous provider is still running background agents or commands, and switching would end them…" (
sessionHasBackgroundWork,apps/server/src/orchestration-v2/Orchestrator.tsaround line 6529). The direct commands reach the detach around line 4327 (thread.archive/thread.settletake every provider session,thread.model-selection.set/provider.switchtakereleaseProviderSessionIds) without that check. Settlement does refuse active or blocked runs, so this only shows up after the run has completed while background work is still running.Related: #30790 (auto-settle right after an awaited task ends), #30430 (provider switch with OpenCode leaves the work running instead), PRs #24446 and #30447.
royalaid commentedon Nov 7, 2026
For the record, from a different angle: we tried telling the thread instead of refusing (let the change through, then deliver one "its provider session was closed when the thread changed (runtime mode changed)" notice naming the awaited tasks). It works, but #24446's refusal keeps the work alive, which is the better outcome, so we are dropping ours in favour of it. Workspace changes and model or provider switches through
thread.model-selection.setandprovider.switchwent through the same silent removal in our tests; worth confirming #24446 covers both.