Skip to content

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

Open

Description

@OscarsOrbit

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" (

// 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 emits provider-session.detached and a detach effect with no pending-background check (
    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,
    ). Claude declares supportsRuntimeModeSwitchInSession: false (
    supportsRuntimeModeSwitchInSession: false,
    ).
  • An instance/runtime/workspace change returns restart_and_resume (
    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" };
    }
    ); the switch plan releases the session (
    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 detach calls releaseEntry(reason: "manual_shutdown"), closing the CLI process (
    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 }),
    ). hasPendingBackgroundWork is consulted only on idle release (
    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
    ); the fix(server): Claude model changes no longer kill running background agents #14726 guard covers query replacement only.

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

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Oct 5, 2026
  2. OscarsOrbit commented on Oct 5, 2026

    @OscarsOrbit
    Author

    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:

    Change made mid-work Valid trials Session detached Background subagent
    Provider instance switch, Claude → Codex (t3_thread_configure) 3 / 3 yes, reason "Provider or model selection changed." failed at the detach, 3 / 3
    Provider instance switch, Claude → a second Claude instance on the same Claude home 3 / 3 yes, same reason string failed at the detach, 3 / 3
    Worktree handoff (t3_worktree_handoff) 3 / 3 yes, reason "Workspace changed." failed at the detach, 3 / 3
    Runtime mode change (reported above) 1 / 1 yes, reason "Runtime mode changed." failed
    Control: model-only change on the same instance 3 / 3 no survived; the next turn was refused with the #14726 guard message
    Control: effort-only change on the same instance 3 / 3 no survived; same guard message
    Baseline: no change 1 / 1 no completed 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.

  3. juliusmarminge commented on Oct 5, 2026

    @juliusmarminge
    Member

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

  4. added
    via-triageFiled through npx t3 triage
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Oct 5, 2026
  5. ScottN-PV commented on Oct 5, 2026

    @ScottN-PV
    Contributor

    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.

    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.

  6. kaovilai commented on Oct 13, 2026

    @kaovilai
    Contributor

    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 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.
    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)

  7. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    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.

  8. mackinleysmith commented on Oct 23, 2026

    @mackinleysmith
    Contributor

    #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.

  9. jamesdchen commented on Nov 2, 2026

    @jamesdchen

    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 Agent subagent, plus other native background subagents, and five delegate_task children running in their own sessions. The thread's runtime mode then changed from auto to full-access from the client, followed by a new message.

    What happened (server trace and thread activity, all 2026-11-02 UTC).

    Time Event
    22:59:57.888 EnvironmentCommands.setThreadRuntimeMode
    22:59:57.908 ProviderSessionManager.detach (20 ms later, exit Success)
    22:59:57.935 The active run ends as failed: "The provider event stream closed unexpectedly. Retry the turn; if it keeps failing, check the provider and server logs."
    22:59:57.935 The native Agent subagent started in that run ends as failed

    What the agent sees afterwards.

    • The call that started that subagent returns only: "Tool call interrupted: the session ended before this call's result was recorded, so its outcome is unknown."
    • A native background subagent from the same session later arrives as a task notification with status stopped: "didn't finish before the previous session ended". Its transcript is kept, but its work stops.
    • Nothing tells the user or the agent that the mode change was the cause. The error reads like a provider fault, and suggests retrying.

    What survived. All five delegate_task children kept running, because they have their own sessions. So the loss only affects native Agent work 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.

  10. marcuscastelo commented on Nov 6, 2026

    @marcuscastelo

    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 with t3_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 with t3_background_wait armed, with the task visible in the shell snapshot's pendingBackgroundTasks before the action:

    Command sent to an idle thread Accepted Background task
    thread.model-selection.set to another Claude instance yes, no refusal <status>killed</status> within seconds; the new session got the killed notice through the handoff and started the command again
    thread.settle (through the MCP t3_thread_organize) yes <status>killed</status>; thread settled with pendingBackgroundTasks empty

    Where 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.ts around line 6529). The direct commands reach the detach around line 4327 (thread.archive / thread.settle take every provider session, thread.model-selection.set / provider.switch take releaseProviderSessionIds) 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.

  11. royalaid commented on Nov 7, 2026

    @royalaid

    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.set and provider.switch went through the same silent removal in our tests; worth confirming #24446 covers both.

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