From fb8d08e74b1044247404fd2407711b9a30cbaa2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:54:04 +0000 Subject: [PATCH 1/2] test(server): settings changes end a Claude session's background work Failing regression tests for #16000: runtime-mode, worktree and provider-instance changes detach a Claude session whose background work probe still reports live work. AI-assisted; verified with the tests and tools listed above. --- .../ProviderSwitch.integration.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 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 306935cdd0975..4ca516284a3b9 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2896,6 +2896,131 @@ describe("orchestration v2 provider switching", () => { ), ); + // regression: #16000 thread setting changes must not end a Claude session's background work + it.live.each([ + ["runtime mode", "runtime-mode"], + ["worktree", "worktree"], + ["provider instance", "instance"], + ] as const)("keeps background work alive when the %s changes on an idle thread", ([, change]) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`settings-background-${change}`); + const nextCwd = yield* checkpointWorkspace(`settings-background-${change}-next`); + const capturedTurns = yield* Ref.make>([]); + const backgroundWork = yield* Ref.make(false); + const closedSessions = yield* Ref.make>([]); + const secondSelection: ModelSelection = { + ...CLAUDE_MODEL_SELECTION, + instanceId: ProviderInstanceId.make("claude-second-account"), + }; + const threadId = ThreadId.make(`thread:settings-background-${change}`); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:settings-background-${change}:create`), + threadId, + projectId: ProjectId.make(`project:settings-background-${change}`), + title: "Settings change with background work", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:settings-background-${change}:send`), + threadId, + messageId: MessageId.make(`message:settings-background-${change}:send`), + text: "Start background work", + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + const afterFirst = yield* waitForIdle(threadId); + const session = afterFirst.providerSessions[0]; + assert.equal(session?.status, "ready"); + // The turn ended but a background subagent keeps running in the CLI process. + yield* Ref.set(backgroundWork, true); + + const commandId = CommandId.make(`command:settings-background-${change}:change`); + yield* orchestrator + .dispatch( + change === "runtime-mode" + ? { + type: "thread.runtime-mode.set", + commandId, + threadId, + runtimeMode: "auto-accept-edits", + } + : change === "worktree" + ? { type: "thread.metadata.update", commandId, threadId, worktreePath: nextCwd } + : { + type: "thread.model-selection.set", + commandId, + threadId, + modelSelection: secondSelection, + }, + ) + .pipe(Effect.ignore); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + // Refusing or deferring are both fine; closing the busy process is not. + assert.deepEqual(yield* Ref.get(closedSessions), []); + assert.deepEqual( + projection.providerSessions.map((candidate) => [candidate.id, candidate.status]), + [[session!.id, "ready"]], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: `settings-background-${change}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 1: "Background work started" }, + capturedTurns, + backgroundWork, + closedSessions, + }), + makeTestAdapter({ + instanceId: secondSelection.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: secondSelection, + responseByRunOrdinal: {}, + capturedTurns, + }), + ]), + ), + ), + ); + }), + ), + ); + it.live.each(["immediately", "behind a running turn"] as const)( "starts a fresh provider session %s and never hands earlier runs to a later provider", (variant) => From 888405f3acf0ec209e8e37a3cf28d8f7543fc2f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:11:50 +0000 Subject: [PATCH 2/2] fix(server): thread setting changes no longer end a Claude session's background work Changing the runtime mode, worktree, or provider instance detached the Claude session at once, killing background subagents and shells running in the CLI process. The thread mutation now probes each session it would detach with the same background-work check message sends use, and refuses the change with a retryable message while work is still running. Archive and settle keep stopping idle background work as before. Fixes pingdotgg/t3code#16000 AI-assisted; verified with the tests and tools listed above. --- .../src/orchestration-v2/Orchestrator.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6a585abc5e5d5..df8c26aad1a38 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4071,6 +4071,42 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }) : null; + // Archive and settle stop idle background work; serialized commands prevent starts racing detach. + // Settlement rejects active or blocked runs. + 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 ?? []), + ); + const liveSessions = (providerContext?.providerSessions ?? []).filter( + (session) => + detachSessionIds.has(session.id) && + session.status !== "stopped" && + session.status !== "error", + ); + if (command.type !== "thread.archive" && command.type !== "thread.settle") { + for (const session of liveSessions) { + if (yield* sessionHasBackgroundWork(session, providerContext!.providerThreads)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "The current session is still running background agents or commands, and this change would end them. Wait for them to finish, or press Stop, then try again.", + }); + } + } + } + const now = yield* DateTime.now; if (command.type === "thread.metadata.update" && command.limitRecovery != null) { const projection = yield* loadProjectionForCommand( @@ -4862,30 +4898,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* closePendingLinkRequests({ command, events, threadId: command.threadId, now }); } - // Archive and settle stop idle background work; serialized commands prevent starts racing detach. - // Settlement rejects active or blocked runs. - 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", - ); + if (liveSessions.length > 0) { yield* Effect.forEach( liveSessions, (session) =>