From 54a5abcd9a6e8b1f44e4879bc0ca29a76a95766a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 00:02:46 +0000 Subject: [PATCH 1/5] test(server): reproduce #20343 stale session after cross-instance send Adds a failing regression test for issue 20343. A thread runs a turn on one Claude instance, then the user sends to a second Claude instance whose native history is separate (a create_with_handoff switch). The previous instance's session should be detached, but dispatchMessage only detaches when canResumeAcrossInstances is true and ignores the switch plan's releaseProviderSessionIds, so both sessions stay live. Co-Authored-By: Claude Opus 5.5 --- .../ProviderSwitch.integration.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 72aae60c0d075..7096946ebdb11 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2225,6 +2225,117 @@ describe("orchestration v2 provider switching", () => { ), ); + // regression: #20343 sending to an instance with separate history must release the old session + it.live( + "detaches the previous instance's session when a send hands off to another instance", + () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("handoff-releases-session"); + const capturedTurns = yield* Ref.make>([]); + const secondSelection: ModelSelection = { + ...CLAUDE_MODEL_SELECTION, + instanceId: ProviderInstanceId.make("claude-second-account"), + }; + // No shared continuation key: each Claude instance owns its own native history. + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 1: "First account complete" }, + capturedTurns, + }), + makeTestAdapter({ + instanceId: secondSelection.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: secondSelection, + responseByRunOrdinal: { 2: "Second account complete" }, + capturedTurns, + }), + ]); + const handoffThreadId = ThreadId.make("thread:handoff-releases-session"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = (ordinal: number, modelSelection: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:handoff-releases-session:${ordinal}`), + threadId: handoffThreadId, + messageId: MessageId.make(`message:handoff-releases-session:${ordinal}`), + text: `Prompt ${ordinal}`, + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:handoff-releases-session:create"), + threadId: handoffThreadId, + projectId: ProjectId.make("project:handoff-releases-session"), + title: "Handoff releases session", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* send(1, CLAUDE_MODEL_SELECTION); + yield* worker.drain(); + const afterFirst = yield* waitForIdle(handoffThreadId); + const firstSession = afterFirst.providerSessions.find( + (session) => session.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + ); + assert.equal(firstSession?.status, "ready"); + + yield* send(2, secondSelection); + yield* worker.drain(); + const afterSecond = yield* waitForIdle(handoffThreadId); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(handoffThreadId); + + assert.deepEqual( + projection.runs.map((run) => [run.providerInstanceId, run.status]), + [ + [CLAUDE_MODEL_SELECTION.instanceId, "completed"], + [secondSelection.instanceId, "completed"], + ], + ); + assert.lengthOf(afterSecond.contextHandoffs, 1); + assert.deepEqual( + projection.providerSessions.map((session) => session.providerInstanceId), + [secondSelection.instanceId], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "handoff-releases-session", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ), + ); + // regression: #21314 a queued PR update must follow the provider that took over the thread it.live.each([ ["automatic", "Claude"], From c70ee2f50704d33cb0ff166997d77187543e8226 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 00:12:14 +0000 Subject: [PATCH 2/5] fix(server): sending to another instance releases the previous session A send that hands the thread to a provider instance with separate native history (create_with_handoff) only detached the old session when the instances shared history. The switch plan's releaseProviderSessionIds was ignored on the direct send path, so the old Claude process stayed bound and kept taking unseen wake turns. dispatchMessage now detaches every session in the plan's release list, as the queued-run and selection-mutation paths do. Detaching ends the provider process and any background agents inside it, so the send is refused with a retryable message while a released session's provider thread still has a background roster that the adapter's hasPendingBackgroundWorkForThread probe confirms. Fixes #20343 Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 87 +++++-- .../ProviderSwitch.integration.test.ts | 242 ++++++++++-------- 2 files changed, 198 insertions(+), 131 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8c8620088fa94..4964964995fe7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5608,24 +5608,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeProviderThread.providerInstanceId !== modelSelection.instanceId; // Account overlays share native history. Selection commands may already // have updated the app thread, so classify against the native thread's owner. + const providerSwitchPlan = isProviderSwitch + ? yield* providerSwitchService + .plan({ + projection: { + ...projection, + thread: { + ...projection.thread, + modelSelection: { + ...projection.thread.modelSelection, + instanceId: activeProviderThread.providerInstanceId, + }, + }, + }, + targetModelSelection: modelSelection, + }) + .pipe(mapDispatchError(command)) + : null; const canResumeAcrossInstances = isProviderSwitch && activeProviderThread.nativeThreadRef !== null && - (yield* providerSwitchService - .plan({ - projection: { - ...projection, - thread: { - ...projection.thread, - modelSelection: { - ...projection.thread.modelSelection, - instanceId: activeProviderThread.providerInstanceId, - }, - }, - }, - targetModelSelection: modelSelection, - }) - .pipe(mapDispatchError(command))).transition.type === "restart_and_resume"; + providerSwitchPlan?.transition.type === "restart_and_resume"; + const sessionsToDetach = projection.providerSessions.filter( + (session) => + providerSwitchPlan?.releaseProviderSessionIds.includes(session.id) && + session.status !== "stopped" && + session.status !== "error", + ); + // Detaching ends the provider process, and background agents and shells + // run inside it. Refuse the switch while one still reports work. + for (const providerThread of projection.providerThreads) { + const detaching = sessionsToDetach.find( + (session) => session.id === providerThread.providerSessionId, + ); + if (detaching === undefined || (providerThread.pendingBackgroundTasks?.length ?? 0) === 0) { + continue; + } + const session = yield* providerSessions + .get(detaching.id) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(session) || session.value.hasPendingBackgroundWorkForThread === undefined) { + continue; + } + const stillRunning = yield* session.value + .hasPendingBackgroundWorkForThread(providerThread) + // A failed probe cannot confirm that the work ended. + .pipe(Effect.catchCause(() => Effect.succeed(true))); + if (stillRunning) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.", + }); + } + } if ( pendingForkTransfer === undefined && @@ -6855,30 +6892,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - if (canResumeAcrossInstances && activeProviderThread.providerSessionId !== null) { - const previousProviderSessionId = activeProviderThread.providerSessionId; + const detachDetail = canResumeAcrossInstances + ? "Provider account changed; continuing the native thread." + : "Provider or model selection changed."; + for (const session of sessionsToDetach) { yield* emitEvent({ type: "provider-session.detached", threadId: command.threadId, - driver: activeProviderThread.driver, - providerInstanceId: activeProviderThread.providerInstanceId, + driver: session.driver, + providerInstanceId: session.providerInstanceId, occurredAt: now, payload: { - providerSessionId: previousProviderSessionId, + providerSessionId: session.id, detachedAt: now, - reason: "Provider account changed; continuing the native thread.", + reason: detachDetail, }, }); yield* Ref.update(effects, (existing) => [ ...existing, { - id: `effect:${command.commandId}:provider-session.detach:${previousProviderSessionId}`, + id: `effect:${command.commandId}:provider-session.detach:${session.id}`, commandId: command.commandId, threadId: command.threadId, request: { type: "provider-session.detach", - providerSessionId: previousProviderSessionId, - detail: "Provider account changed; continuing the native thread.", + providerSessionId: session.id, + detail: detachDetail, }, } satisfies PendingOrchestrationEffectV2, ]); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 7096946ebdb11..990f9335ef677 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -116,6 +116,7 @@ function makeTestAdapter(input: { readonly holdRunOrdinal?: number; readonly holdFirstTurn?: Deferred.Deferred; readonly releaseFirstTurn?: Deferred.Deferred; + readonly backgroundWork?: Ref.Ref; }): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: input.instanceId, @@ -151,6 +152,9 @@ function makeTestAdapter(input: { ...(input.canReuseContextUsage === undefined ? {} : { canReuseContextUsage: input.canReuseContextUsage }), + ...(input.backgroundWork === undefined + ? {} + : { hasPendingBackgroundWorkForThread: () => Ref.get(input.backgroundWork!) }), ensureThread: (threadInput) => Effect.gen(function* () { const createdAt = yield* DateTime.now; @@ -675,16 +679,7 @@ describe("orchestration v2 provider switching", () => { yield* wait(3); } if (replaceNative) { - const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("detach-for-native-replacement"), - threadId, - providerSessionId: target.providerSessionId!, - }); - yield* worker.drain(); + // Switching back to Codex already released Claude's session. yield* Ref.set(failResumeOnce, true); } if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); @@ -2226,114 +2221,147 @@ describe("orchestration v2 provider switching", () => { ); // regression: #20343 sending to an instance with separate history must release the old session - it.live( - "detaches the previous instance's session when a send hands off to another instance", - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("handoff-releases-session"); - const capturedTurns = yield* Ref.make>([]); - const secondSelection: ModelSelection = { - ...CLAUDE_MODEL_SELECTION, - instanceId: ProviderInstanceId.make("claude-second-account"), - }; - // No shared continuation key: each Claude instance owns its own native history. - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeTestAdapter({ - instanceId: CLAUDE_MODEL_SELECTION.instanceId, - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 1: "First account complete" }, - capturedTurns, - }), - makeTestAdapter({ - instanceId: secondSelection.instanceId, - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: secondSelection, - responseByRunOrdinal: { 2: "Second account complete" }, - capturedTurns, - }), - ]); - const handoffThreadId = ThreadId.make("thread:handoff-releases-session"); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const send = (ordinal: number, modelSelection: ModelSelection) => - orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`command:handoff-releases-session:${ordinal}`), - threadId: handoffThreadId, - messageId: MessageId.make(`message:handoff-releases-session:${ordinal}`), - text: `Prompt ${ordinal}`, - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* orchestrator.dispatch({ - type: "thread.create", + it.live.each([ + ["detaches the previous instance's session when a send hands off to another instance", false], + ["refuses the hand-off while the previous instance still runs background work", true], + ] as const)("%s", ([, backgroundRunning]) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("handoff-releases-session"); + const capturedTurns = yield* Ref.make>([]); + const backgroundWork = yield* Ref.make(backgroundRunning); + const secondSelection: ModelSelection = { + ...CLAUDE_MODEL_SELECTION, + instanceId: ProviderInstanceId.make("claude-second-account"), + }; + // No shared continuation key: each Claude instance owns its own native history. + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 1: "First account complete" }, + capturedTurns, + backgroundWork, + }), + makeTestAdapter({ + instanceId: secondSelection.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: secondSelection, + responseByRunOrdinal: { 2: "Second account complete" }, + capturedTurns, + }), + ]); + const handoffThreadId = ThreadId.make("thread:handoff-releases-session"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + const send = (ordinal: number, modelSelection: ModelSelection, attempt = "") => + orchestrator.dispatch({ + type: "message.dispatch", createdBy: "user", creationSource: "web", - commandId: CommandId.make("command:handoff-releases-session:create"), + commandId: CommandId.make(`command:handoff-releases-session:${ordinal}${attempt}`), threadId: handoffThreadId, - projectId: ProjectId.make("project:handoff-releases-session"), - title: "Handoff releases session", - modelSelection: CLAUDE_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, + messageId: MessageId.make(`message:handoff-releases-session:${ordinal}${attempt}`), + text: `Prompt ${ordinal}`, + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, }); - yield* send(1, CLAUDE_MODEL_SELECTION); - yield* worker.drain(); - const afterFirst = yield* waitForIdle(handoffThreadId); - const firstSession = afterFirst.providerSessions.find( - (session) => session.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - ); - assert.equal(firstSession?.status, "ready"); - - yield* send(2, secondSelection); - yield* worker.drain(); - const afterSecond = yield* waitForIdle(handoffThreadId); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(handoffThreadId); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:handoff-releases-session:create"), + threadId: handoffThreadId, + projectId: ProjectId.make("project:handoff-releases-session"), + title: "Handoff releases session", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* send(1, CLAUDE_MODEL_SELECTION); + yield* worker.drain(); + const afterFirst = yield* waitForIdle(handoffThreadId); + const firstSession = afterFirst.providerSessions.find( + (session) => session.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + ); + assert.equal(firstSession?.status, "ready"); - assert.deepEqual( - projection.runs.map((run) => [run.providerInstanceId, run.status]), - [ - [CLAUDE_MODEL_SELECTION.instanceId, "completed"], - [secondSelection.instanceId, "completed"], + if (backgroundRunning) { + yield* eventSink.write({ + events: [ + { + id: EventId.make("handoff-releases-session:background-roster"), + type: "provider-thread.updated", + threadId: handoffThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...afterFirst.providerThreads[0]!, + pendingBackgroundTasks: [ + { taskId: "agent", description: "Background agent", kind: "subagent" }, + ], + }, + }, ], - ); - assert.lengthOf(afterSecond.contextHandoffs, 1); + }); + const refused = yield* send(2, secondSelection, ":refused").pipe(Effect.flip); + assert.include(String(refused.cause), "still running background agents or commands"); + const unchanged = yield* orchestrator.getThreadProjection(handoffThreadId); + assert.lengthOf(unchanged.runs, 1); + assert.equal(unchanged.thread.providerInstanceId, CLAUDE_MODEL_SELECTION.instanceId); assert.deepEqual( - projection.providerSessions.map((session) => session.providerInstanceId), - [secondSelection.instanceId], + unchanged.providerSessions.map((session) => [session.id, session.status]), + [[firstSession!.id, "ready"]], ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { - name: "handoff-releases-session", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, + yield* Ref.set(backgroundWork, false); + } + + yield* send(2, secondSelection); + yield* worker.drain(); + const afterSecond = yield* waitForIdle(handoffThreadId); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(handoffThreadId); + + assert.deepEqual( + projection.runs.map((run) => [run.providerInstanceId, run.status]), + [ + [CLAUDE_MODEL_SELECTION.instanceId, "completed"], + [secondSelection.instanceId, "completed"], + ], + ); + assert.lengthOf(afterSecond.contextHandoffs, 1); + assert.deepEqual( + projection.providerSessions.map((session) => session.providerInstanceId), + [secondSelection.instanceId], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "handoff-releases-session", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, }, }, - layerRegistry, - ), + }, + layerRegistry, ), - ); - }), - ), + ), + ); + }), + ), ); // regression: #21314 a queued PR update must follow the provider that took over the thread From ecd96ca98e3588e6ec745de27188e5ee0d15642e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 00:19:38 +0000 Subject: [PATCH 3/5] test(server): hand-off test observes the released session's runtime close The #20343 regression test only checked that the projection dropped the old session, which the detached event alone satisfies. The fake adapter now records when a session's runtime scope closes, so the test fails if the detach effect is dropped and the old provider process keeps running. Co-Authored-By: Claude Opus 5.5 --- .../testkit/ProviderSwitch.integration.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 990f9335ef677..cc773b0468487 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -117,6 +117,7 @@ function makeTestAdapter(input: { readonly holdFirstTurn?: Deferred.Deferred; readonly releaseFirstTurn?: Deferred.Deferred; readonly backgroundWork?: Ref.Ref; + readonly closedSessions?: Ref.Ref>; }): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: input.instanceId, @@ -126,6 +127,12 @@ function makeTestAdapter(input: { openSession: (sessionInput) => Effect.gen(function* () { const events = yield* PubSub.unbounded(); + if (input.closedSessions !== undefined) { + const closedSessions = input.closedSessions; + yield* Effect.addFinalizer(() => + Ref.update(closedSessions, (ids) => [...ids, sessionInput.providerSessionId]), + ); + } const now = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: sessionInput.providerSessionId, @@ -2230,6 +2237,7 @@ describe("orchestration v2 provider switching", () => { const cwd = yield* checkpointWorkspace("handoff-releases-session"); const capturedTurns = yield* Ref.make>([]); const backgroundWork = yield* Ref.make(backgroundRunning); + const closedSessions = yield* Ref.make>([]); const secondSelection: ModelSelection = { ...CLAUDE_MODEL_SELECTION, instanceId: ProviderInstanceId.make("claude-second-account"), @@ -2244,6 +2252,7 @@ describe("orchestration v2 provider switching", () => { responseByRunOrdinal: { 1: "First account complete" }, capturedTurns, backgroundWork, + closedSessions, }), makeTestAdapter({ instanceId: secondSelection.instanceId, @@ -2315,6 +2324,7 @@ describe("orchestration v2 provider switching", () => { assert.include(String(refused.cause), "still running background agents or commands"); const unchanged = yield* orchestrator.getThreadProjection(handoffThreadId); assert.lengthOf(unchanged.runs, 1); + assert.deepEqual(yield* Ref.get(closedSessions), []); assert.equal(unchanged.thread.providerInstanceId, CLAUDE_MODEL_SELECTION.instanceId); assert.deepEqual( unchanged.providerSessions.map((session) => [session.id, session.status]), @@ -2341,6 +2351,8 @@ describe("orchestration v2 provider switching", () => { projection.providerSessions.map((session) => session.providerInstanceId), [secondSelection.instanceId], ); + // The detached event alone hides the session; the runtime must also close. + assert.deepEqual(yield* Ref.get(closedSessions), [firstSession!.id]); }).pipe( Effect.provide( ProviderReplayHarness.layerWithRegistry( From ccbaab5a84cf288fa73d9fdf70c89fda0685bfa5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 00:25:54 +0000 Subject: [PATCH 4/5] fix(server): hand-off checks the released Claude process for native subagents Claude's native background subagents are tracked only in the adapter's session-wide state, never in the projected pendingBackgroundTasks or the per-thread roster that hasPendingBackgroundWorkForThread reads. The guard skipped probing when the projected roster was empty and only asked the per-thread probe, so a send to another instance detached the old session and killed a running background Agent. The guard now probes every session it is about to detach. A session that owns one provider thread (Claude) is checked with the session-wide hasPendingBackgroundWork, which sees running subagents and wake buffers; a session shared across threads keeps the per-thread probe. The regression test no longer injects a fake roster, and its adapter reports work only through the session-wide probe, as Claude does. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 50 +++++++++++-------- .../ProviderSwitch.integration.test.ts | 27 ++++------ 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4964964995fe7..191c35197d876 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5636,31 +5636,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio session.status !== "error", ); // Detaching ends the provider process, and background agents and shells - // run inside it. Refuse the switch while one still reports work. - for (const providerThread of projection.providerThreads) { - const detaching = sessionsToDetach.find( - (session) => session.id === providerThread.providerSessionId, - ); - if (detaching === undefined || (providerThread.pendingBackgroundTasks?.length ?? 0) === 0) { - continue; - } + // run inside it. Refuse the switch while one still reports work. Ask the + // adapter even with an empty projected roster: Claude's native subagents + // never reach pendingBackgroundTasks, and only its session-wide probe sees + // them. A session shared across threads is checked per thread instead. + for (const detaching of sessionsToDetach) { const session = yield* providerSessions .get(detaching.id) .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isNone(session) || session.value.hasPendingBackgroundWorkForThread === undefined) { + if (Option.isNone(session)) { continue; } - const stillRunning = yield* session.value - .hasPendingBackgroundWorkForThread(providerThread) - // A failed probe cannot confirm that the work ended. - .pipe(Effect.catchCause(() => Effect.succeed(true))); - if (stillRunning) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: - "The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.", - }); + const runtime = session.value; + const probes = + !detaching.capabilities.sessions.supportsMultipleProviderThreadsPerSession && + runtime.hasPendingBackgroundWork !== undefined + ? [runtime.hasPendingBackgroundWork] + : projection.providerThreads.flatMap((providerThread) => + providerThread.providerSessionId === detaching.id && + runtime.hasPendingBackgroundWorkForThread !== undefined + ? [runtime.hasPendingBackgroundWorkForThread(providerThread)] + : [], + ); + for (const probe of probes) { + const stillRunning = yield* probe.pipe( + // A failed probe cannot confirm that the work ended. + Effect.catchCause(() => Effect.succeed(true)), + ); + if (stillRunning) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.", + }); + } } } diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index cc773b0468487..f8bbc6f0b794c 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -161,7 +161,12 @@ function makeTestAdapter(input: { : { canReuseContextUsage: input.canReuseContextUsage }), ...(input.backgroundWork === undefined ? {} - : { hasPendingBackgroundWorkForThread: () => Ref.get(input.backgroundWork!) }), + : { + // Like Claude: native subagents are visible only to the session-wide + // probe, never to the per-thread roster probe. + hasPendingBackgroundWork: Ref.get(input.backgroundWork), + hasPendingBackgroundWorkForThread: () => Effect.succeed(false), + }), ensureThread: (threadInput) => Effect.gen(function* () { const createdAt = yield* DateTime.now; @@ -2267,7 +2272,6 @@ describe("orchestration v2 provider switching", () => { yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const eventSink = yield* EventSink.EventSinkV2; const send = (ordinal: number, modelSelection: ModelSelection, attempt = "") => orchestrator.dispatch({ type: "message.dispatch", @@ -2304,22 +2308,9 @@ describe("orchestration v2 provider switching", () => { assert.equal(firstSession?.status, "ready"); if (backgroundRunning) { - yield* eventSink.write({ - events: [ - { - id: EventId.make("handoff-releases-session:background-roster"), - type: "provider-thread.updated", - threadId: handoffThreadId, - occurredAt: yield* DateTime.now, - payload: { - ...afterFirst.providerThreads[0]!, - pendingBackgroundTasks: [ - { taskId: "agent", description: "Background agent", kind: "subagent" }, - ], - }, - }, - ], - }); + // Native subagents live only in the adapter's roster, so the projection's + // pendingBackgroundTasks stays empty while the probe still reports work. + assert.deepEqual(afterFirst.providerThreads[0]?.pendingBackgroundTasks ?? [], []); const refused = yield* send(2, secondSelection, ":refused").pipe(Effect.flip); assert.include(String(refused.cause), "still running background agents or commands"); const unchanged = yield* orchestrator.getThreadProjection(handoffThreadId); From 4f08ecdf0b0075c365c8d85d815e13d9b1caca83 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:12:30 -0800 Subject: [PATCH 5/5] chore(server): shorten the provider switch background-work comment Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Orchestrator.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 46e8c1478c2c7..3dc0a65ae03a3 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -6268,11 +6268,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio session.status !== "stopped" && session.status !== "error", ); - // Detaching ends the provider process, and background agents and shells - // run inside it. Refuse the switch while one still reports work. Ask the - // adapter even with an empty projected roster: Claude's native subagents - // never reach pendingBackgroundTasks, and only its session-wide probe sees - // them. A session shared across threads is checked per thread instead. + // Detaching kills background work running inside the provider process. + // Probe even with an empty roster: Claude's native subagents never reach + // pendingBackgroundTasks. Sessions shared across threads probe per thread. for (const detaching of sessionsToDetach) { const session = yield* providerSessions .get(detaching.id)