authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-02 14:28:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 14:28:49-07:00
logb4d3d51ac99d4306d754afb5c78c49845bfac3c1
tree84aeda4d3427aa11082f2498d443ea93cb1fb2a6
parent8bc40b4e07bb7b4b0f71876d59520360c9bf958c
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude model changes no longer kill running background agents (#14726)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 258 insertions(+), 15 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+184-13
...@@ -62,6 +62,7 @@ import {...@@ -62,6 +62,7 @@ import {
62 type ProviderAdapterV2TurnInput,62 type ProviderAdapterV2TurnInput,
63} from "../ProviderAdapter.ts";63} from "../ProviderAdapter.ts";
64import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";64import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
65import { makeProviderFailure } from "../ProviderFailure.ts";
65import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";66import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";
66import * as IdAllocator from "../IdAllocator.ts";67import * as IdAllocator from "../IdAllocator.ts";
6768
...@@ -6608,12 +6609,25 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6608,12 +6609,25 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6608 assert.equal(processQueues.length, 1);6609 assert.equal(processQueues.length, 1);
6609 const firstProcess = processQueues[0]!;6610 const firstProcess = processQueues[0]!;
6610 yield* Queue.offer(firstProcess, wakeTaskStarted);6611 yield* Queue.offer(firstProcess, wakeTaskStarted);
6612 // The shell leaves the roster before its notification arrives, so
6613 // nothing runs in this process any more and a model change may
6614 // replace it. Wake eligibility outlives the empty level.
6615 yield* Queue.offer(
6616 firstProcess,
6617 claudeSdkFrame({
6618 type: "system",
6619 subtype: "background_tasks_changed",
6620 tasks: [],
6621 uuid: "00000000-0000-4000-8000-000000000603",
6622 session_id: WAKE_NATIVE_SESSION,
6623 }),
6624 );
6611 yield* Queue.offer(firstProcess, turnOneResult);6625 yield* Queue.offer(firstProcess, turnOneResult);
6612 yield* awaitUntil(6626 yield* awaitUntil(
6613 () => events.some((event) => event.type === "turn.terminal"),6627 () => events.some((event) => event.type === "turn.terminal"),
6614 "first turn terminal",6628 "first turn terminal",
6615 );6629 );
6616 assert.isTrue(yield* hasPendingBackgroundWork);6630 assert.isFalse(yield* hasPendingBackgroundWork);
66176631
6618 const alternateModel = {6632 const alternateModel = {
6619 ...CLAUDE_TEST_MODEL_SELECTION,6633 ...CLAUDE_TEST_MODEL_SELECTION,
...@@ -7117,8 +7131,172 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7117,8 +7131,172 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7117 ),7131 ),
7118 );7132 );
71197133
7134 it.effect("refuses a model change that would kill a running background subagent", () =>
7135 Effect.scoped(
7136 Effect.gen(function* () {
7137 const SUBAGENT_TASK_ID = "task-model-change-running-subagent";
7138 const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent";
7139 const fileSystem = yield* FileSystem.FileSystem;
7140 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7141 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
7142 prefix: "t3-claude-v2-model-change-running-subagent-",
7143 });
7144 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
7145 const events: Array<ProviderAdapterV2Event> = [];
7146 const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({
7147 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
7148 settings: DEFAULT_CLAUDE_SETTINGS,
7149 environment: {},
7150 attachmentsDir,
7151 fileSystem,
7152 path: yield* Path.Path,
7153 idAllocator,
7154 continuationRequests: { offer: () => Effect.void },
7155 queryRunner: {
7156 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
7157 open: () =>
7158 Effect.gen(function* () {
7159 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
7160 processQueues.push(sdkMessages);
7161 return {
7162 messages: Stream.fromQueue(sdkMessages),
7163 offer: () => Effect.void,
7164 setModel: () => Effect.void,
7165 interrupt: Effect.void,
7166 close: Queue.shutdown(sdkMessages),
7167 };
7168 }),
7169 forkSession: () => Effect.die("unused forkSession"),
7170 subagentLaunchToolUseId: () => Effect.succeed(null),
7171 assertComplete: Effect.void,
7172 },
7173 });
7174 const threadId = ThreadId.make("thread-claude-model-change-running-subagent");
7175 const runtime = yield* adapter.openSession({
7176 threadId,
7177 providerSessionId: ProviderSessionId.make(
7178 "provider-session-claude-model-change-running-subagent",
7179 ),
7180 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
7181 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
7182 });
7183 const providerThread = yield* runtime.ensureThread({
7184 threadId,
7185 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
7186 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
7187 });
7188 yield* runtime.events.pipe(
7189 Stream.runForEach((event) =>
7190 Effect.sync(() => {
7191 events.push(event);
7192 }),
7193 ),
7194 Effect.forkScoped,
7195 );
7196 const terminals = () => events.filter((event) => event.type === "turn.terminal");
7197 const now = yield* DateTime.now;
7198
7199 yield* runtime.startTurn(
7200 makeClaudeTestTurnInput({
7201 threadId,
7202 providerThread,
7203 now,
7204 attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"),
7205 text: "Spawn a background subagent and stop.",
7206 attachments: [],
7207 }),
7208 );
7209 const firstProcess = processQueues[0]!;
7210 yield* Queue.offer(
7211 firstProcess,
7212 claudeSdkFrame({
7213 type: "system",
7214 subtype: "task_started",
7215 task_id: SUBAGENT_TASK_ID,
7216 tool_use_id: SUBAGENT_TOOL_USE_ID,
7217 description: "Background research",
7218 subagent_type: "general-purpose",
7219 task_type: "local_agent",
7220 prompt: "Research, then report.",
7221 uuid: "00000000-0000-4000-8000-000000000901",
7222 session_id: WAKE_NATIVE_SESSION,
7223 }),
7224 );
7225 yield* Queue.offer(
7226 firstProcess,
7227 makeResultFrame({
7228 uuid: "00000000-0000-4000-8000-000000000902",
7229 result: "Spawned the subagent in the background.",
7230 }),
7231 );
7232 yield* awaitUntil(() => terminals().length === 1, "first turn terminal");
7233 const settledTurn = terminals()[0]!;
7234
7235 // The subagent runs inside the first CLI process. Another model needs
7236 // another process, so the turn must not start and close this one.
7237 const alternateModel = {
7238 ...CLAUDE_TEST_MODEL_SELECTION,
7239 model: "claude-haiku-4-5-20251001",
7240 } satisfies ModelSelection;
7241 const switchTurn = (attempt: string) =>
7242 runtime.startTurn(
7243 makeClaudeTestTurnInput({
7244 threadId,
7245 providerThread: { ...providerThread, status: "active" },
7246 now,
7247 attemptId: RunAttemptId.make(attempt),
7248 text: "Switch model while the subagent runs.",
7249 attachments: [],
7250 providerTurnOrdinal: 2,
7251 modelSelection: alternateModel,
7252 }),
7253 );
7254 const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe(
7255 Effect.flip,
7256 );
7257 assert.equal(
7258 makeProviderFailure({ cause: refused, class: "provider_error" }).message,
7259 new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message,
7260 );
7261 assert.lengthOf(processQueues, 1);
7262
7263 // Stop ends the background work, so the switch may replace the process.
7264 yield* runtime.interruptTurn({
7265 providerThread,
7266 providerTurnId: settledTurn.providerTurnId,
7267 requestRuntimeRestart: true,
7268 });
7269 yield* switchTurn("attempt-claude-model-change-running-subagent-c");
7270 assert.lengthOf(processQueues, 2);
7271
7272 // The stopped subagent never reports its end, so it must not block
7273 // later changes on the replacement process either.
7274 yield* Queue.offer(
7275 processQueues[1]!,
7276 makeResultFrame({
7277 uuid: "00000000-0000-4000-8000-000000000903",
7278 result: "Switched model.",
7279 }),
7280 );
7281 yield* awaitUntil(() => terminals().length === 2, "switched turn terminal");
7282 yield* runtime.startTurn(
7283 makeClaudeTestTurnInput({
7284 threadId,
7285 providerThread: { ...providerThread, status: "active" },
7286 now,
7287 attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"),
7288 text: "Switch back.",
7289 attachments: [],
7290 providerTurnOrdinal: 3,
7291 }),
7292 );
7293 assert.lengthOf(processQueues, 3);
7294 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7295 ),
7296 );
7297
7120 it.effect(7298 it.effect(
7121 "clears process-scoped roster when same-native-thread replacement open fails after close",7299 "keeps the process and its roster when a model change meets a running background shell",
7122 () =>7300 () =>
7123 Effect.scoped(7301 Effect.scoped(
7124 Effect.gen(function* () {7302 Effect.gen(function* () {
...@@ -7236,17 +7414,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7236,17 +7414,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7236 )7414 )
7237 .pipe(Effect.exit);7415 .pipe(Effect.exit);
7238 assert.isTrue(Exit.isFailure(failedStart));7416 assert.isTrue(Exit.isFailure(failedStart));
7239 // Old process was closed before the failed open: roster must not stick.7417 // The shell runs in the first process, so it is never closed and
7240 yield* awaitUntil(7418 // no replacement is opened.
7241 () =>7419 assert.equal(openCount, 1);
7242 providerThreadRosterEvents(events).some(7420 assert.isTrue(yield* hasPendingBackgroundWork);
7243 (event) =>
7244 event.providerThread.status === "idle" &&
7245 (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0,
7246 ),
7247 "roster cleared after failed same-thread replacement open",
7248 );
7249 assert.isFalse(yield* hasPendingBackgroundWork);
7250 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),7421 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
7251 ),7422 ),
7252 );7423 );
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+72-2
...@@ -340,6 +340,15 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError<ClaudeAge...@@ -340,6 +340,15 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError<ClaudeAge
340 }340 }
341}341}
342342
343export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.TaggedError<ClaudeBackgroundWorkBlocksQueryReplacementError>()(
344 "ClaudeBackgroundWorkBlocksQueryReplacementError",
345 {},
346) {
347 override get message(): string {
348 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.";
349 }
350}
351
343export interface ClaudeAgentSdkQueryRunnerShape {352export interface ClaudeAgentSdkQueryRunnerShape {
344 readonly allocateSessionId: Effect.Effect<string, ClaudeAgentSdkQueryRunnerError>;353 readonly allocateSessionId: Effect.Effect<string, ClaudeAgentSdkQueryRunnerError>;
345 readonly open: (354 readonly open: (
...@@ -2667,6 +2676,12 @@ interface ClaudeLiveQueryContext {...@@ -2667,6 +2676,12 @@ interface ClaudeLiveQueryContext {
2667 // uuid before any echo, so it echoes, but a resume's own turns can still2676 // uuid before any echo, so it echoes, but a resume's own turns can still
2668 // run ahead of that prompt.2677 // run ahead of that prompt.
2669 promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only";2678 promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only";
2679 // Stop, rollback or fork is closing this process; its work is ending.
2680 stopping: boolean;
2681 // Registry entries still running when this process opened. Their process
2682 // is gone and never reports their end; any later task_started replaces the
2683 // entry, so an entry still in this set runs nowhere.
2684 readonly subagentsFromEarlierProcesses: ReadonlySet<ActiveClaudeSubagent>;
2670}2685}
26712686
2672interface ActiveClaudeToolCall {2687interface ActiveClaudeToolCall {
...@@ -6753,6 +6768,37 @@ export function makeClaudeAdapterV2(...@@ -6753,6 +6768,37 @@ export function makeClaudeAdapterV2(
6753 }),6768 }),
6754 );6769 );
67556770
6771 // Work the live process still runs. A subagent whose completion is
6772 // already buffered is done: the buffer outlives the process.
6773 const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* (
6774 live: ClaudeLiveQueryContext,
6775 ) {
6776 if (
6777 rosterForNativeThread(
6778 yield* Ref.get(pendingBackgroundTasksByNativeThread),
6779 live.nativeThreadId,
6780 ).size > 0
6781 ) {
6782 return true;
6783 }
6784 const buffered = (yield* Ref.get(wakeBuffers)).get(live.nativeThreadId)?.messages ?? [];
6785 for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) {
6786 if (
6787 subagent.task.status === "running" &&
6788 !live.subagentsFromEarlierProcesses.has(subagent) &&
6789 !buffered.some(
6790 (message) =>
6791 message.type === "system" &&
6792 message.subtype === "task_notification" &&
6793 message.task_id === taskId,
6794 )
6795 ) {
6796 return true;
6797 }
6798 }
6799 return false;
6800 });
6801
6756 const openQuery = Effect.fnUntraced(function* (6802 const openQuery = Effect.fnUntraced(function* (
6757 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,6803 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
6758 nativeThreadId: string,6804 nativeThreadId: string,
...@@ -6770,15 +6816,32 @@ export function makeClaudeAdapterV2(...@@ -6770,15 +6816,32 @@ export function makeClaudeAdapterV2(
6770 const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection);6816 const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection);
6771 const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread);6817 const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread);
6772 const existing = yield* Ref.get(queryContext);6818 const existing = yield* Ref.get(queryContext);
6819 // A continuation prompts nothing: it drains output the live process
6820 // already produced, so it keeps that process whatever its selection.
6773 if (6821 if (
6774 existing !== null &&6822 existing !== null &&
6775 existing.nativeThreadId === nativeThreadId &&6823 existing.nativeThreadId === nativeThreadId &&
6776 existing.queryPolicyKey === queryPolicyKey &&6824 (isClaudeProviderContinuationTurn(turnInput) ||
6777 existing.selectionKey === compiledSelection.queryIdentity6825 (existing.queryPolicyKey === queryPolicyKey &&
6826 existing.selectionKey === compiledSelection.queryIdentity))
6778 ) {6827 ) {
6779 return existing;6828 return existing;
6780 }6829 }
67816830
6831 // Background agents and shells run inside the CLI process, so a
6832 // new selection would kill them and lose their results. Refuse until
6833 // they finish or the user presses Stop, which closes the process.
6834 // Another native thread on this session is one the app thread has
6835 // left (Claude sessions serve one app thread), so it is replaced.
6836 if (
6837 existing !== null &&
6838 existing.nativeThreadId === nativeThreadId &&
6839 !existing.stopping &&
6840 (yield* liveProcessRunsBackgroundWork(existing))
6841 ) {
6842 return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError();
6843 }
6844
6782 // openQuery owns one live process. Closing it for another native6845 // openQuery owns one live process. Closing it for another native
6783 // thread kills that sibling's CLI; it can never emit a roster clear,6846 // thread kills that sibling's CLI; it can never emit a roster clear,
6784 // so drop its process-scoped Waiting/wake state immediately. Closing6847 // so drop its process-scoped Waiting/wake state immediately. Closing
...@@ -6875,6 +6938,12 @@ export function makeClaudeAdapterV2(...@@ -6875,6 +6938,12 @@ export function makeClaudeAdapterV2(
6875 selectionKey: compiledSelection.queryIdentity,6938 selectionKey: compiledSelection.queryIdentity,
6876 closed,6939 closed,
6877 promptEchoMode: "unknown",6940 promptEchoMode: "unknown",
6941 stopping: false,
6942 subagentsFromEarlierProcesses: new Set(
6943 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter(
6944 (subagent) => subagent.task.status === "running",
6945 ),
6946 ),
6878 };6947 };
6879 yield* Ref.set(queryContext, context);6948 yield* Ref.set(queryContext, context);
6880 yield* querySession.messages.pipe(6949 yield* querySession.messages.pipe(
...@@ -7239,6 +7308,7 @@ export function makeClaudeAdapterV2(...@@ -7239,6 +7308,7 @@ export function makeClaudeAdapterV2(
7239 return;7308 return;
7240 }7309 }
72417310
7311 existing.stopping = true;
7242 yield* existing.query.close.pipe(Effect.ignore);7312 yield* existing.query.close.pipe(Effect.ignore);
7243 const closed = yield* Deferred.await(existing.closed).pipe(7313 const closed = yield* Deferred.await(existing.closed).pipe(
7244 Effect.timeoutOption("10 seconds"),7314 Effect.timeoutOption("10 seconds"),
apps/server/src/orchestration-v2/ProviderFailure.ts+2
...@@ -36,6 +36,8 @@ function causeMessage(cause: unknown): string | undefined {...@@ -36,6 +36,8 @@ function causeMessage(cause: unknown): string | undefined {
36 switch ((cause as Record<string, unknown>)._tag) {36 switch ((cause as Record<string, unknown>)._tag) {
37 case "ContextHandoffBudgetError":37 case "ContextHandoffBudgetError":
38 return new ContextHandoffBudgetError().message;38 return new ContextHandoffBudgetError().message;
39 case "ClaudeBackgroundWorkBlocksQueryReplacementError":
40 return stringField(cause, "message");
39 case "ContextHandoffDeliveryUncertainError":41 case "ContextHandoffDeliveryUncertainError":
40 return "T3 could not confirm whether conversation history reached the provider. Retry the turn to recover the session.";42 return "T3 could not confirm whether conversation history reached the provider. Retry the turn to recover the session.";
41 case "ProviderAdapterTurnStartError":43 case "ProviderAdapterTurnStartError":