From 8ffdbeab67a06787a4b8be22e0dcd3b560a5b83a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:52:42 -0700 Subject: [PATCH 1/3] fix(server): Claude model changes no longer kill running background agents A Claude model or option change opens a new CLI process and closed the old one, killing background agents and shells still running in it. Refuse that replacement with a clear message while the live process runs background work; Stop still ends the work and allows the change. Continuation turns only drain output the live process already produced, so they keep that process. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 142 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 54 ++++++- .../src/orchestration-v2/ProviderFailure.ts | 2 + 3 files changed, 196 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 467d58702df0..fc5d0b1ffbd7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -62,6 +62,7 @@ import { type ProviderAdapterV2TurnInput, } from "../ProviderAdapter.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "../IdAllocator.ts"; @@ -7117,6 +7118,147 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("refuses a model change that would kill a running background subagent", () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent"; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-model-change-running-subagent-", + }); + const processQueues: Array> = []; + const events: Array = []; + const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + idAllocator, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-model-change-running-subagent"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-claude-model-change-running-subagent", + ), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + const terminals = () => events.filter((event) => event.type === "turn.terminal"); + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + const firstProcess = processQueues[0]!; + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Background research", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Research, then report.", + uuid: "00000000-0000-4000-8000-000000000901", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + firstProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000902", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); + const settledTurn = terminals()[0]!; + + // The subagent runs inside the first CLI process. Another model needs + // another process, so the turn must not start and close this one. + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + const switchTurn = (attempt: string) => + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make(attempt), + text: "Switch model while the subagent runs.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe( + Effect.flip, + ); + assert.equal( + makeProviderFailure({ cause: refused, class: "provider_error" }).message, + new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message, + ); + assert.lengthOf(processQueues, 1); + + // Stop ends the background work, so the switch may replace the process. + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: settledTurn.providerTurnId, + requestRuntimeRestart: true, + }); + yield* switchTurn("attempt-claude-model-change-running-subagent-c"); + assert.lengthOf(processQueues, 2); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect( "clears process-scoped roster when same-native-thread replacement open fails after close", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c383a2455118..b453d63756e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -339,6 +339,15 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError()( + "ClaudeBackgroundWorkBlocksQueryReplacementError", + {}, +) { + override get message(): string { + return "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."; + } +} + export interface ClaudeAgentSdkQueryRunnerShape { readonly allocateSessionId: Effect.Effect; readonly open: ( @@ -6650,6 +6659,34 @@ export function makeClaudeAdapterV2( }), ); + // Work the live process still runs. A subagent whose completion is + // already buffered is done: the buffer outlives the process. + const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* (nativeThreadId: string) { + if ( + rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ).size > 0 + ) { + return true; + } + const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { + if ( + subagent.task.status === "running" && + !buffered.some( + (message) => + message.type === "system" && + message.subtype === "task_notification" && + message.task_id === taskId, + ) + ) { + return true; + } + } + return false; + }); + const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, @@ -6667,15 +6704,28 @@ export function makeClaudeAdapterV2( const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); + // A continuation prompts nothing: it drains output the live process + // already produced, so it keeps that process whatever its selection. if ( existing !== null && existing.nativeThreadId === nativeThreadId && - existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity + (isClaudeProviderContinuationTurn(turnInput) || + (existing.queryPolicyKey === queryPolicyKey && + existing.selectionKey === compiledSelection.queryIdentity)) ) { return existing; } + // Background agents and shells run inside the CLI process, so a + // replacement would kill them and lose their results. Refuse until + // they finish or the user presses Stop, which closes the process. + if ( + existing !== null && + (yield* liveProcessRunsBackgroundWork(existing.nativeThreadId)) + ) { + return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); + } + // openQuery owns one live process. Closing it for another native // thread kills that sibling's CLI; it can never emit a roster clear, // so drop its process-scoped Waiting/wake state immediately. Closing diff --git a/apps/server/src/orchestration-v2/ProviderFailure.ts b/apps/server/src/orchestration-v2/ProviderFailure.ts index b0c7e5fdd627..6cb9275017d9 100644 --- a/apps/server/src/orchestration-v2/ProviderFailure.ts +++ b/apps/server/src/orchestration-v2/ProviderFailure.ts @@ -36,6 +36,8 @@ function causeMessage(cause: unknown): string | undefined { switch ((cause as Record)._tag) { case "ContextHandoffBudgetError": return new ContextHandoffBudgetError().message; + case "ClaudeBackgroundWorkBlocksQueryReplacementError": + return stringField(cause, "message"); case "ContextHandoffDeliveryUncertainError": return "T3 could not confirm whether conversation history reached the provider. Retry the turn to recover the session."; case "ProviderAdapterTurnStartError": From 0ea993d90698b49c1eb15237c3c7fd486c363f7a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:05:50 -0700 Subject: [PATCH 2/3] fix(server): refuse only a same-thread replacement, and let a closing Stop through Another native thread on the session belongs to a provider thread its app thread has already left, since Claude sessions serve one app thread, so its process is still replaced. A settled Stop that is still waiting for the CLI to exit marks the query as stopping, and a turn started meanwhile may replace it. Adapter tests that replaced a process while a background shell ran now either let the shell leave the roster first or assert the refusal. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 32 +++++++++++-------- .../Adapters/ClaudeAdapterV2.ts | 12 +++++-- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fc5d0b1ffbd7..18e058bdf714 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6609,12 +6609,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(processQueues.length, 1); const firstProcess = processQueues[0]!; yield* Queue.offer(firstProcess, wakeTaskStarted); + // The shell leaves the roster before its notification arrives, so + // nothing runs in this process any more and a model change may + // replace it. Wake eligibility outlives the empty level. + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000603", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer(firstProcess, turnOneResult); yield* awaitUntil( () => events.some((event) => event.type === "turn.terminal"), "first turn terminal", ); - assert.isTrue(yield* hasPendingBackgroundWork); + assert.isFalse(yield* hasPendingBackgroundWork); const alternateModel = { ...CLAUDE_TEST_MODEL_SELECTION, @@ -7260,7 +7273,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "clears process-scoped roster when same-native-thread replacement open fails after close", + "keeps the process and its roster when a model change meets a running background shell", () => Effect.scoped( Effect.gen(function* () { @@ -7378,17 +7391,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); - // Old process was closed before the failed open: roster must not stick. - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - "roster cleared after failed same-thread replacement open", - ); - assert.isFalse(yield* hasPendingBackgroundWork); + // The shell runs in the first process, so it is never closed and + // no replacement is opened. + assert.equal(openCount, 1); + assert.isTrue(yield* hasPendingBackgroundWork); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b453d63756e1..3c2f5d2728ca 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2615,6 +2615,8 @@ interface ClaudeLiveQueryContext { // uuid before any echo, so it echoes, but a resume's own turns can still // run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; + // Stop, rollback or fork is closing this process; its work is ending. + stopping: boolean; } interface ActiveClaudeToolCall { @@ -6717,11 +6719,15 @@ export function makeClaudeAdapterV2( } // Background agents and shells run inside the CLI process, so a - // replacement would kill them and lose their results. Refuse until + // 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 && - (yield* liveProcessRunsBackgroundWork(existing.nativeThreadId)) + existing.nativeThreadId === nativeThreadId && + !existing.stopping && + (yield* liveProcessRunsBackgroundWork(nativeThreadId)) ) { return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); } @@ -6822,6 +6828,7 @@ export function makeClaudeAdapterV2( selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", + stopping: false, }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( @@ -7186,6 +7193,7 @@ export function makeClaudeAdapterV2( return; } + existing.stopping = true; yield* existing.query.close.pipe(Effect.ignore); const closed = yield* Deferred.await(existing.closed).pipe( Effect.timeoutOption("10 seconds"), From 69463d6d52a4fe8c43c13d1595b665598bd906f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:15:35 -0700 Subject: [PATCH 3/3] fix(server): a subagent from a closed Claude process no longer blocks model changes A subagent registered as running when its CLI process was closed (Stop, rollback, fork, or the replacement this guard allows) never reports its end, so it kept refusing later model changes. The live query now remembers which running subagents predate it and only counts the ones it started. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 23 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 20 ++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 18e058bdf714..6d3d1f9cdd07 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7268,6 +7268,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); yield* switchTurn("attempt-claude-model-change-running-subagent-c"); assert.lengthOf(processQueues, 2); + + // The stopped subagent never reports its end, so it must not block + // later changes on the replacement process either. + yield* Queue.offer( + processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000903", + result: "Switched model.", + }), + ); + yield* awaitUntil(() => terminals().length === 2, "switched turn terminal"); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"), + text: "Switch back.", + attachments: [], + providerTurnOrdinal: 3, + }), + ); + assert.lengthOf(processQueues, 3); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3c2f5d2728ca..4a99b64c18ab 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2617,6 +2617,10 @@ interface ClaudeLiveQueryContext { promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; + // Registry entries still running when this process opened. Their process + // is gone and never reports their end; any later task_started replaces the + // entry, so an entry still in this set runs nowhere. + readonly subagentsFromEarlierProcesses: ReadonlySet; } interface ActiveClaudeToolCall { @@ -6663,19 +6667,22 @@ export function makeClaudeAdapterV2( // Work the live process still runs. A subagent whose completion is // already buffered is done: the buffer outlives the process. - const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* (nativeThreadId: string) { + const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* ( + live: ClaudeLiveQueryContext, + ) { if ( rosterForNativeThread( yield* Ref.get(pendingBackgroundTasksByNativeThread), - nativeThreadId, + live.nativeThreadId, ).size > 0 ) { return true; } - const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + const buffered = (yield* Ref.get(wakeBuffers)).get(live.nativeThreadId)?.messages ?? []; for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { if ( subagent.task.status === "running" && + !live.subagentsFromEarlierProcesses.has(subagent) && !buffered.some( (message) => message.type === "system" && @@ -6727,7 +6734,7 @@ export function makeClaudeAdapterV2( existing !== null && existing.nativeThreadId === nativeThreadId && !existing.stopping && - (yield* liveProcessRunsBackgroundWork(nativeThreadId)) + (yield* liveProcessRunsBackgroundWork(existing)) ) { return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); } @@ -6829,6 +6836,11 @@ export function makeClaudeAdapterV2( closed, promptEchoMode: "unknown", stopping: false, + subagentsFromEarlierProcesses: new Set( + [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( + (subagent) => subagent.task.status === "running", + ), + ), }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe(