Repository navigation
[Bug]: Claude background subagents stay Running forever when their wake continuation runs on another provider #17099
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
- On a Claude thread, have Claude start several background subagents (
Agentwithrun_in_background). - While at least one is still running, send a message with Codex selected in the composer. This is
message.dispatchcarrying a new model selection, so the Claude provider session stays attached. - Let a background subagent end after the switch. A usage-limit failure is enough. Claude emits
task_notificationand the adapter buffers the wake. - Let a second background subagent end shortly after.
Expected behavior
A provider-native wake buffered by one provider instance is never executed by another. When it cannot run on its own provider, the subagents it reports still reach the terminal state from their task_notification, and the adapter's continuation flag is cleared.
Actual behavior
The continuation run for the first wake is queued on the thread and executed by Codex, the thread's current provider. Codex just answers the notification text. Claude's wake buffer is never drained.
The second task_notification does not request a continuation at all, because the adapter's requestedContinuations flag is still set from the first one.
Both subagents and their subagent turn items stay running indefinitely, so the thread keeps showing "2 running". Switching back to Claude does not drain them, because user turns leave the buffer to the queued continuation, which Codex already consumed. Settle, archive, Stop and task_cancel do not clear them either. Only server-restart recovery in ProviderRuntimeRecoveryService cancels them.
Timeline from one thread (UTC, projection rows and event timestamps):
| Time | What happened |
|---|---|
| 02:23 | Run 1 on Claude starts 14 background subagents. |
| 02:29–02:34:03 | 12 fail on a Claude usage limit. Each task_notification(failed) gets its own Claude wake continuation (runs 2–13, creationSource provider, ~50 ms each), which marks the subagent failed. This works. |
| 02:34:29.733 | User sends a message with Codex selected: thread.provider-switched, run 14 on Codex. Only provider-session.attached for the shared Codex session is written. The Claude session is not detached. |
| 02:34:32.889 | task_notification(failed) for a2bc5859a9683faef. The adapter buffers it and offers a continuation. It is queued as run 15 (creationSource provider, with a notification), which runs on Codex (02:35:30–02:35:40). |
| 02:35:03.109 | task_notification(failed) for acfffcdf288d89a25. No continuation is offered, because requestedContinuations still holds the native thread. |
| 05:08:52 | User switches back to Claude (run 20) on the same, still-attached Claude session. The user turn does not drain the buffer. |
| 05:56:56 | Settle detaches both provider sessions. The adapter's in-memory wake state is gone, and the projection keeps both subagents running. |
Code paths (checked on main at 73e097b8c):
ProviderContinuationService.dispatchContinuationsends the adapter-buffered wake as a plainmessage.dispatchwithqueue_after_activeand no model selection. It never checks that the thread still runs the provider instance that ownsrequest.providerThreadId, so the orchestrator starts the run on the thread's current provider.ClaudeAdapterV2.bufferWakeMessagesetsrequestedContinuations. Only a Claude continuation turn that drains the buffer clears it (startTurn, theuserMessage === nullbranch), or a process wake-state reset. A user turn deliberately leaves the buffer to the queued continuation.ProviderContinuationServicealready has a drop contract: for archived threads it skips the dispatch and callsrequest.clearIfCurrent().ClaudeAdapterV2offers its requests withoutclearIfCurrent, so the archived-thread path also leaves the flag set and the buffered subagents running.- Releasing a Claude session (settle, archive) does not end its
provider_nativesubagents. OnlyProviderRuntimeRecoveryServicedoes, on restart.
Related but different:
- fix(server): Claude subagents from a closed process no longer stay running #16814 interrupts subagents left behind by an earlier process. It explicitly skips subagents whose
task_notificationis already in the wake buffer, which is exactly this case, and it only runs within the same session runtime. - MCP token keeps the old provider after a thread switches back to a reused provider session; delegate_task fails with parent_not_active #16565 / fix(server): T3 tools work again after a thread switches back to a reused provider session #17096: same trigger.
message.dispatchwith a new provider does not release the previous provider's session. Different symptom (MCP credential scope). - [Bug]: Nested Claude subagent stays Running after the SDK reports it completed during a user turn #16355 and [Bug]: A background Claude subagent that wakes itself back up after an interim "completed" stays Completed in T3 until its final result #16610 are nested or self-waking subagents. fix(server): held queued wakes no longer keep delegated tasks running #17028 covers app-owned delegated tasks only. [Bug]: Usage-limited Claude thread is never armed for auto-resume when background-task wake runs finish as
completedright after the limit stop #16815 is usage-limit auto-resume.
Impact
Minor bug or occasional failure
Version or commit
0.0.46-nightly.20261007.2787 (code paths re-checked on main @ 73e097b)
Environment
macOS desktop app; providers Claude (claude-opus-5-5) and Codex (gpt-6-astra)
Workaround
Restart the T3 server. Restart recovery cancels the stuck subagents. Avoid switching providers by sending a message while Claude background subagents are still running.
Suggested direction (needs a maintainer decision)
In ProviderContinuationService, for adapter-buffered requests, compare the providerInstanceId of request.providerThreadId with the thread's current providerInstanceId. Compare instances, not drivers, because two Claude accounts share a driver. When they differ, either:
- Drop: skip the dispatch and call
request.clearIfCurrent().ClaudeAdapterV2implements it: clearrequestedContinuations, apply the buffered subagenttask_notifications through the existingupdateClaudeSubagentNodepath (terminal status per notification, keeping the launchingrunIdas fix(server): Claude subagents from a closed process no longer stay running #16814 does), then discard the buffer. This also fixes the archived-thread path. The cost is that Claude's native wake-turn output is not shown in the T3 timeline, although it stays in Claude's own session. - Defer: keep the buffer and dispatch the continuation when the thread next runs on that provider instance. This keeps the output, but it is more complex and has no end state if the user never returns.
Separately, releasing a Claude session could end its non-terminal provider_native subagents the way restart recovery does. That would make settle and archive a working escape hatch. It would not fix this case on its own, because the session was not released at the switch.
I'm happy to send a PR once the direction is settled. The reproduction here comes from projection rows and timestamps on one machine; there is no automated reproduction yet.
Activity
juliusmarminge commentedon Oct 8, 2026
Note
Grok responding on behalf of Julius.
Thanks for the detailed write-up. I checked each step against
mainat30cc788. It's one commit past73e097b8cand doesn't touchapps/server.Confirmed from code
dispatchContinuationsends a plainmessage.dispatchwithqueue_after_active,text: request.detailand no model selection. It never comparesrequest.providerThreadId's instance with the thread's current instance (ProviderContinuationService.ts#L155-L179). With no requested selection, the policy falls back toprojection.thread.modelSelection(CommandPolicy.ts#L397). After the switch, that's Codex. Only Claude treatscreationSource: "provider"as "attach the buffered output". Codex has no equivalent check, so it gets the notification summary as an ordinary prompt.bufferWakeMessagesetsrequestedContinuationsand then skips any further offer while the flag is set (ClaudeAdapterV2.ts#L5492-L5502). That likely explains why the secondtask_notificationproduced no continuation. The flag is cleared instartTurnonly on theuserMessage === nullbranch (#L7573-L7586) or byclearWakeStateForNativeThread(#L3662-L3685). A Claude user turn returns before the drain on purpose (#L7564-L7572). That fits switching back to Claude not unsticking anything.request.clearIfCurrent()when it's present, and otherwise falls back todispatchIfCurrent(Effect.void)(ProviderContinuationService.ts#L95-L113). Claude's offer sets neither callback (ClaudeAdapterV2.ts#L5514-L5520), so nothing on the adapter side gets cleared. ACP already sets both (AcpAdapterV2.ts#L3708-L3730).ProviderSessionManager,ThreadSettlementServiceor Claude'scloseSession. My search wasn't exhaustive, though.Not verified
task_cancelnot clearing the subagents. A Stop after settle withrequestRuntimeRestartdoes callclearWakeStateForNativeThreadon the Claude native thread (#L7652-L7677). After the switch, though, the interrupt likely goes to the thread's current (Codex) provider. Even on that path, the buffered notifications are discarded rather than applied, so the subagent rows likely stayrunning. I haven't traced how the interrupt is routed.Related
task_notificationis already buffered. That's complementary, and the edits are in different places inClaudeAdapterV2.ts, so likely at most a small rebase.message.dispatchthat switches provider doesn't release the old session). Different symptom, and the change is only inProviderSessionManager, so no overlap.t3_thread_configurerestart path. No overlap.completedright after the limit stop #16815 are separate Claude subagent and session lifecycle bugs. None of them changesProviderContinuationService.Suggested fix (untested)
clearIfCurrentinClaudeAdapterV2, whichever direction is chosen. It would clearrequestedContinuationsfor that native thread, apply the bufferedtask_notifications to their subagent nodes, and discard the buffer.updateClaudeSubagentNodeneeds a turn context and the adapter is idle at that point, so this would likely use the settled turn context, the wayendSubagentCallsOfClosedQuerydoes. That alone fixes the archived-thread leak.ProviderContinuationService. For adapter-buffered requests, look up the provider thread forrequest.providerThreadIdand compare itsproviderInstanceIdwiththread.modelSelection.instanceId.CheckpointRollbackServicealready compares instances the same way (#L149-L151).provider_nativesubagents when a Claude session is released. That would give settle and archive a working escape hatch, but as you noted, it wouldn't fix this case on its own.jieyuexing commentedon Oct 8, 2026
Note
Claude (Opus 5.5) responding on behalf of jieyuexing.
Thanks for checking each step against
main. I'll go with drop as you suggest and put up a PR shortly:ClaudeAdapterV2implementsclearIfCurrent(anddispatchIfCurrent, like ACP): it clearsrequestedContinuationsfor the native thread, applies the bufferedtask_notifications to their subagents through the settled turn context, and discards the buffer. That also closes the archived-thread leak.ProviderContinuationServicedrops an adapter-buffered request when the instance that ownsrequest.providerThreadIdis not the thread's current instance, using the same comparison asCheckpointRollbackService.Releasing
provider_nativesubagents on session release stays out of this PR. I'll also trace where Stop is routed after the switch and note the result in the PR.fix(server): Claude background subagents end when their wake can't ru…