Skip to content

[Bug]: Claude background subagents stay Running forever when their wake continuation runs on another provider #17099

Description

@jieyuexing

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. On a Claude thread, have Claude start several background subagents (Agent with run_in_background).
  2. While at least one is still running, send a message with Codex selected in the composer. This is message.dispatch carrying a new model selection, so the Claude provider session stays attached.
  3. Let a background subagent end after the switch. A usage-limit failure is enough. Claude emits task_notification and the adapter buffers the wake.
  4. 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):

  1. ProviderContinuationService.dispatchContinuation sends the adapter-buffered wake as a plain message.dispatch with queue_after_active and no model selection. It never checks that the thread still runs the provider instance that owns request.providerThreadId, so the orchestrator starts the run on the thread's current provider.
  2. ClaudeAdapterV2.bufferWakeMessage sets requestedContinuations. Only a Claude continuation turn that drains the buffer clears it (startTurn, the userMessage === null branch), or a process wake-state reset. A user turn deliberately leaves the buffer to the queued continuation.
  3. ProviderContinuationService already has a drop contract: for archived threads it skips the dispatch and calls request.clearIfCurrent(). ClaudeAdapterV2 offers its requests without clearIfCurrent, so the archived-thread path also leaves the flag set and the buffered subagents running.
  4. Releasing a Claude session (settle, archive) does not end its provider_native subagents. Only ProviderRuntimeRecoveryService does, on restart.

Related but different:

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(). ClaudeAdapterV2 implements it: clear requestedContinuations, apply the buffered subagent task_notifications through the existing updateClaudeSubagentNode path (terminal status per notification, keeping the launching runId as 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

  1. juliusmarminge commented on Oct 8, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the detailed write-up. I checked each step against main at 30cc788. It's one commit past 73e097b8c and doesn't touch apps/server.

    Confirmed from code

    1. The wake runs on whatever provider the thread uses now. For adapter-buffered requests, dispatchContinuation sends a plain message.dispatch with queue_after_active, text: request.detail and no model selection. It never compares request.providerThreadId's instance with the thread's current instance (ProviderContinuationService.ts#L155-L179). With no requested selection, the policy falls back to projection.thread.modelSelection (CommandPolicy.ts#L397). After the switch, that's Codex. Only Claude treats creationSource: "provider" as "attach the buffered output". Codex has no equivalent check, so it gets the notification summary as an ordinary prompt.
    2. The sticky flag only clears when a Claude continuation drains the buffer. bufferWakeMessage sets requestedContinuations and then skips any further offer while the flag is set (ClaudeAdapterV2.ts#L5492-L5502). That likely explains why the second task_notification produced no continuation. The flag is cleared in startTurn only on the userMessage === null branch (#L7573-L7586) or by clearWakeStateForNativeThread (#L3662-L3685). A Claude user turn returns before the drain on purpose (#L7564-L7572). That fits switching back to Claude not unsticking anything.
    3. The archived-thread drop path leaks the same flag. The service calls request.clearIfCurrent() when it's present, and otherwise falls back to dispatchIfCurrent(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).
    4. Restart recovery is the cleanup path I found. It cancels non-terminal, non-app-owned subagents of the runs it recovers (ProviderRuntimeRecoveryService.ts#L365-L384). I found nothing comparable in ProviderSessionManager, ThreadSettlementService or Claude's closeSession. My search wasn't exhaustive, though.

    Not verified

    • Stop and task_cancel not clearing the subagents. A Stop after settle with requestRuntimeRestart does call clearWakeStateForNativeThread on 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 stay running. I haven't traced how the interrupt is routed.
    • The timeline itself. I haven't reproduced it, but every step lines up with the code above.

    Related

    Suggested fix (untested)

    • Implement clearIfCurrent in ClaudeAdapterV2, whichever direction is chosen. It would clear requestedContinuations for that native thread, apply the buffered task_notifications to their subagent nodes, and discard the buffer. updateClaudeSubagentNode needs a turn context and the adapter is idle at that point, so this would likely use the settled turn context, the way endSubagentCallsOfClosedQuery does. That alone fixes the archived-thread leak.
    • Add an instance check in ProviderContinuationService. For adapter-buffered requests, look up the provider thread for request.providerThreadId and compare its providerInstanceId with thread.modelSelection.instanceId. CheckpointRollbackService already compares instances the same way (#L149-L151).
    • Drop vs. defer is a maintainer decision. Drop looks like the lower-risk default: it reuses the existing archived-thread contract, every subagent ends up in a terminal state, and the wake output likely stays in Claude's native session for when the thread resumes on Claude. Defer keeps the output in the T3 timeline, but it needs durable pending state and has no end state if the user never switches back.
    • Optionally, end provider_native subagents 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.
  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 8, 2026
  3. jieyuexing commented on Oct 8, 2026

    @jieyuexing
    Author

    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:

    • ClaudeAdapterV2 implements clearIfCurrent (and dispatchIfCurrent, like ACP): it clears requestedContinuations for the native thread, applies the buffered task_notifications to their subagents through the settled turn context, and discards the buffer. That also closes the archived-thread leak.
    • ProviderContinuationService drops an adapter-buffered request when the instance that owns request.providerThreadId is not the thread's current instance, using the same comparison as CheckpointRollbackService.

    Releasing provider_native subagents 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions