From 3456c3fa6784efbde1b54dd1b320b77d8b04079c Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Mon, 12 Oct 2026 09:50:30 +0300 Subject: [PATCH 1/4] fix(server): Claude threads recover after you log in again The Claude CLI reads its login once per process, and a thread keeps one process alive between turns. After a turn fails to authenticate, mark that process signed out so the next turn replaces it with a fresh one that resumes the same conversation. The sign-in error tells the user to send the message again instead of starting a new thread. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 62 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 13 +++- .../fixtures/claude_result_is_error/output.ts | 2 +- .../src/provider/Drivers/ClaudeHome.test.ts | 2 +- .../server/src/provider/Drivers/ClaudeHome.ts | 2 +- 5 files changed, 77 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 7460977ecb1b6..1ff83ad3c8a0c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3198,6 +3198,68 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { failure: "authentication_failed", opensFreshProcess: true }, + { failure: "server_error", opensFreshProcess: false }, + ] as const)( + "after a turn fails with $failure, the next turn opens a fresh process: $opensFreshProcess", + ({ failure, opensFreshProcess }) => + Effect.gen(function* () { + let closeCount = 0; + const harness = yield* makeWakeHarnessWithOptions({ + freshQueueOnReopen: true, + close: () => + Effect.sync(() => { + closeCount += 1; + }), + }); + const now = yield* DateTime.now; + const startTurn = (attempt: string, text: string) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(attempt), + text, + attachments: [], + }), + ); + + yield* startTurn("attempt-claude-relogin-1", "Continue."); + yield* Queue.offerAll(harness.sdkMessages, [ + makeAssistantErrorFrame({ uuid: "00000000-0000-4000-8000-000000000611", error: failure }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000612", + result: "API Error", + terminalReason: "api_error", + isError: true, + }), + ]); + const failed = yield* Queue.take(harness.terminalReceipts); + assert.equal(failed.status, "failed"); + + yield* startTurn("attempt-claude-relogin-2", "Try again."); + assert.equal(harness.processQueues.length, opensFreshProcess ? 2 : 1); + assert.equal(closeCount, opensFreshProcess ? 1 : 0); + if (opensFreshProcess) { + // The fresh process resumes the same Claude conversation. + assert.equal(harness.getOpenedOptions()?.resume, WAKE_NATIVE_SESSION); + assert.isUndefined(harness.getOpenedOptions()?.sessionId); + } + yield* Queue.offer( + harness.processQueues.at(-1)!, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000613", result: "Done." }), + ); + const completed = yield* Queue.take(harness.terminalReceipts); + assert.equal(completed.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each([ { recovered: false, expected: "Claude usage limit reached" }, { recovered: true, expected: "Claude gave up after repeated API errors" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7199f73040ef9..2c79d4f6e6d36 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3006,6 +3006,9 @@ interface ClaudeLiveQueryContext { permissionMode: PermissionMode; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; + // A turn failed to authenticate. The CLI reads its login once per process, + // so the next turn replaces this process to pick up a new login. + signedOut: 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. @@ -7615,9 +7618,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; + const status = interrupted + ? "interrupted" + : terminalStatusFromResult(message, failureHint); + if (status === "failed" && context.authenticationFailureMessage !== undefined) { + liveQuery.signedOut = true; + } yield* finalizeActiveTurn({ context, - status: interrupted ? "interrupted" : terminalStatusFromResult(message, failureHint), + status, completedAt, result: message, ...(terminalFailure === null ? {} : { failure: terminalFailure }), @@ -8218,6 +8227,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( existing !== null && existing.nativeThreadId === nativeThreadId && + !existing.signedOut && existing.queryPolicyKey === queryPolicyKey && existing.selectionKey === compiledSelection.queryIdentity ) { @@ -8353,6 +8363,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( openedPermissionMode: queryOptions.permissionMode, permissionMode: queryOptions.permissionMode, stopping: false, + signedOut: false, subagentsFromEarlierProcesses: new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( (subagent) => subagent.task.status === "running", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts index cbf68ee481f6d..b03ac487b7f34 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/output.ts @@ -13,7 +13,7 @@ import { const AUTH_ERROR_TEXT = "Failed to authenticate. API Error: 401 Invalid authentication credentials"; const AUTH_FAILURE_TEXT = - "Claude could not authenticate. For subscription login, run `claude auth login` on this environment's machine, then start a new thread. For API-key authentication, check this instance's configured credentials."; + "Claude could not authenticate. For subscription login, run `claude auth login` on this environment's machine, then send the message again. For API-key authentication, check this instance's configured credentials."; export function assertClaudeResultIsErrorOutput( result: OrchestratorV2ScenarioResult, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index fb1caf753f869..107b146f85848 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -73,7 +73,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { const message = claudeSignedOutMessage({ configDir, cwd: "/synthetic/project" }); expect(message).toContain(`CLAUDE_CONFIG_DIR set to "${configDir}"`); expect(message).not.toContain("CLAUDE_CONFIG_DIR="); - expect(message).toContain("then start a new thread"); + expect(message).toContain("then send the message again"); }); it.effect("separates capability probes by cwd", () => diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 78bb34c2fd9ef..ab42ff50f55ab 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -86,5 +86,5 @@ export const claudeSignedOutMessage = (input: { input.configDir !== undefined ? ` from ${quotePath(input.cwd)}, with CLAUDE_CONFIG_DIR set to ${quotePath(input.configDir)}` : ""; - return `Claude could not authenticate. For subscription login, run \`claude auth login\` on this environment's machine${configuration}, then start a new thread. For API-key authentication, check this instance's configured credentials.`; + return `Claude could not authenticate. For subscription login, run \`claude auth login\` on this environment's machine${configuration}, then send the message again. For API-key authentication, check this instance's configured credentials.`; }; From 4613e9b02311882700852364e57c1b38f51dac26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:55:31 +0000 Subject: [PATCH 2/4] fix(server): replace signed-out Claude processes on retry --- .../Adapters/ClaudeAdapterV2.testkit.ts | 11 +++++++---- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 1 + .../claude_result_is_error/claude_transcript.ndjson | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ec3ec5b052461..a8be77d7e9282 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -526,6 +526,7 @@ function makeReplayQueryRunner( ): AsyncGenerator { while (true) { throwIfFailed(); + if (signal.aborted) return; const entry = transcript.entries[cursor]; if (entry === undefined) { @@ -623,9 +624,9 @@ function makeReplayQueryRunner( const replayMessagesWithGateCleanup = ( options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions, + abortController: AbortController, ): AsyncIterable => ({ [Symbol.asyncIterator]: () => { - const abortController = new AbortController(); iteratorAbortControllers.add(abortController); const iterator = replayMessages(options, abortController.signal); return { @@ -808,9 +809,11 @@ function makeReplayQueryRunner( return { open: (input) => { assertNextOutboundFrame(makeClaudeQueryOpenFrame(input)); + const abortController = new AbortController(); return { - messages: Stream.fromAsyncIterable(replayMessagesWithGateCleanup(input.options), (cause) => - replayQueryRunnerError(transcript, cause), + messages: Stream.fromAsyncIterable( + replayMessagesWithGateCleanup(input.options, abortController), + (cause) => replayQueryRunnerError(transcript, cause), ), offer: (message) => replayEffect(() => { @@ -848,7 +851,7 @@ function makeReplayQueryRunner( interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), - close: Effect.void, + close: Effect.sync(() => abortController.abort()), }; }, forkSession: (input) => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4464a7da67f56..404f9788ca207 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8480,6 +8480,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( existing !== null && existing.nativeThreadId === nativeThreadId && + !existing.signedOut && existing.queryPolicyKey === queryPolicyKey && existing.selection.queryIdentity === compiledSelection.queryIdentity ) { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/claude_transcript.ndjson index 2597109378ee2..8c4553f98d196 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/claude_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_result_is_error/claude_transcript.ndjson @@ -4,6 +4,7 @@ {"type":"emit_inbound","label":"system:init:1","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.183","cwd":"/tmp/claude-replay-claude_result_is_error","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"aa401aa4-0000-4000-8000-000000000001","session_id":"aa401aa4-e597-4e40-8e40-1e401e401e40"}} {"type":"emit_inbound","label":"assistant:synthetic_auth_error","frame":{"type":"assistant","message":{"id":"7f28a402-6d4c-491e-abbb-2b3c231a06f0","container":null,"model":"","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}],"context_management":null},"parent_tool_use_id":null,"session_id":"aa401aa4-e597-4e40-8e40-1e401e401e40","uuid":"aa401aa4-0000-4000-8000-000000000002","error":"authentication_failed"}} {"type":"emit_inbound","label":"result:1_is_error","frame":{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"duration_ms":2116,"duration_api_ms":0,"num_turns":1,"result":"Failed to authenticate. API Error: 401 Invalid authentication credentials","stop_reason":"stop_sequence","session_id":"aa401aa4-e597-4e40-8e40-1e401e401e40","total_cost_usd":0,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"aa401aa4-0000-4000-8000-000000000003"}} +{"type":"expect_outbound","label":"query.open:resume","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"settings":{"showThinkingSummaries":true},"resume":"aa401aa4-e597-4e40-8e40-1e401e401e40"}}} {"type":"expect_outbound","label":"prompt.offer:2","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Try again now that auth is back."},"parent_tool_use_id":null}}} {"type":"emit_inbound","label":"assistant:recovered","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01iserrorfixture1","type":"message","role":"assistant","content":[{"type":"text","text":"claude result is_error fixture recovered"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"aa401aa4-e597-4e40-8e40-1e401e401e40","uuid":"aa401aa4-0000-4000-8000-000000000004"}} {"type":"emit_inbound","label":"result:2_success","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":4000,"duration_api_ms":3500,"num_turns":1,"result":"claude result is_error fixture recovered","stop_reason":"end_turn","session_id":"aa401aa4-e597-4e40-8e40-1e401e401e40","total_cost_usd":0.001,"usage":{"input_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":2,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":2,"outputTokens":2,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.001,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"aa401aa4-0000-4000-8000-000000000005"}} From 64640f71b234e7dc149cb8d3ad6ebbe1cc6692a9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 09:09:46 +0000 Subject: [PATCH 3/4] fix(server): preserve Claude reuse after permission errors --- .../orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 10 ++++++---- .../Adapters/ClaudeAdapterV2.testkit.ts | 11 +++++------ .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 29068b8f2ee14..ca60d0bebfa00 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3580,11 +3580,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect.each([ - { failure: "authentication_failed", opensFreshProcess: true }, - { failure: "server_error", opensFreshProcess: false }, + { failure: "authentication_failed", apiErrorStatus: undefined, opensFreshProcess: true }, + { failure: "server_error", apiErrorStatus: undefined, opensFreshProcess: false }, + { failure: "authentication_failed", apiErrorStatus: 403, opensFreshProcess: false }, ] as const)( - "after a turn fails with $failure, the next turn opens a fresh process: $opensFreshProcess", - ({ failure, opensFreshProcess }) => + "after a turn fails with $failure (API status: $apiErrorStatus), the next turn opens a fresh process: $opensFreshProcess", + ({ failure, apiErrorStatus, opensFreshProcess }) => Effect.gen(function* () { let closeCount = 0; const harness = yield* makeWakeHarnessWithOptions({ @@ -3613,6 +3614,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000612", result: "API Error", + ...(apiErrorStatus === undefined ? {} : { apiErrorStatus }), terminalReason: "api_error", isError: true, }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index a8be77d7e9282..21efea9cf8f69 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -624,9 +624,9 @@ function makeReplayQueryRunner( const replayMessagesWithGateCleanup = ( options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions, - abortController: AbortController, ): AsyncIterable => ({ [Symbol.asyncIterator]: () => { + const abortController = new AbortController(); iteratorAbortControllers.add(abortController); const iterator = replayMessages(options, abortController.signal); return { @@ -808,12 +808,11 @@ function makeReplayQueryRunner( return { open: (input) => { + abortReplayIterators(); assertNextOutboundFrame(makeClaudeQueryOpenFrame(input)); - const abortController = new AbortController(); return { - messages: Stream.fromAsyncIterable( - replayMessagesWithGateCleanup(input.options, abortController), - (cause) => replayQueryRunnerError(transcript, cause), + messages: Stream.fromAsyncIterable(replayMessagesWithGateCleanup(input.options), (cause) => + replayQueryRunnerError(transcript, cause), ), offer: (message) => replayEffect(() => { @@ -851,7 +850,7 @@ function makeReplayQueryRunner( interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), - close: Effect.sync(() => abortController.abort()), + close: Effect.void, }; }, forkSession: (input) => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 404f9788ca207..d27862c97ac95 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -481,7 +481,7 @@ export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.Tagg {}, ) { 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."; + return "Claude is still running background agents or commands, and starting a new Claude process would end them. Wait for them to finish, or press Stop, then send the message again."; } } @@ -7895,7 +7895,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : limitedCompaction !== null ? "failed" : terminalStatusFromResult(message, failureHint); - if (status === "failed" && context.authenticationFailureMessage !== undefined) { + if ( + status === "failed" && + context.authenticationFailureMessage !== undefined && + !permissionDenied + ) { liveQuery.signedOut = true; } yield* finalizeActiveTurn({ From 5bdf0e7a092e5ef528912bc7762b3050824536a8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 07:27:23 +0000 Subject: [PATCH 4/4] fix(server): retain background shells during Claude login recovery --- .../Adapters/ClaudeAdapterV2.test.ts | 55 ++++++++++++++++--- .../Adapters/ClaudeAdapterV2.ts | 4 +- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index adbc477c93ccf..ae013bb36c89d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4024,20 +4024,41 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); it.effect.each([ - { failure: "authentication_failed", apiErrorStatus: undefined, opensFreshProcess: true }, - { failure: "server_error", apiErrorStatus: undefined, opensFreshProcess: false }, - { failure: "authentication_failed", apiErrorStatus: 403, opensFreshProcess: false }, + { + failure: "authentication_failed", + apiErrorStatus: undefined, + opensFreshProcess: true, + backgroundShell: false, + }, + { + failure: "authentication_failed", + apiErrorStatus: undefined, + opensFreshProcess: true, + backgroundShell: true, + }, + { + failure: "server_error", + apiErrorStatus: undefined, + opensFreshProcess: false, + backgroundShell: false, + }, + { + failure: "authentication_failed", + apiErrorStatus: 403, + opensFreshProcess: false, + backgroundShell: false, + }, ] as const)( - "after a turn fails with $failure (API status: $apiErrorStatus), the next turn opens a fresh process: $opensFreshProcess", - ({ failure, apiErrorStatus, opensFreshProcess }) => + "after a turn fails with $failure (API status: $apiErrorStatus), the next turn opens a fresh process: $opensFreshProcess (background shell: $backgroundShell)", + ({ failure, apiErrorStatus, opensFreshProcess, backgroundShell }) => Effect.gen(function* () { let closeCount = 0; const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true, - close: () => + close: (messages) => Effect.sync(() => { closeCount += 1; - }), + }).pipe(Effect.andThen(Queue.shutdown(messages))), }); const now = yield* DateTime.now; const startTurn = (attempt: string, text: string) => @@ -4053,6 +4074,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* startTurn("attempt-claude-relogin-1", "Continue."); + if (backgroundShell) yield* harness.offerAndWait(wakeTaskStarted); yield* Queue.offerAll(harness.sdkMessages, [ makeAssistantErrorFrame({ uuid: "00000000-0000-4000-8000-000000000611", error: failure }), makeResultFrame({ @@ -4066,6 +4088,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { const failed = yield* Queue.take(harness.terminalReceipts); assert.equal(failed.status, "failed"); + if (backgroundShell) { + const refused = yield* startTurn("attempt-claude-relogin-blocked", "Try again.").pipe( + Effect.result, + ); + assert.equal(refused._tag, "Failure"); + if (refused._tag === "Failure") { + assert.instanceOf( + refused.failure.cause, + ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, + ); + } + assert.lengthOf(harness.processQueues, 1); + assert.equal(closeCount, 0); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: failed.providerTurnId, + requestRuntimeRestart: true, + }); + } yield* startTurn("attempt-claude-relogin-2", "Try again."); assert.equal(harness.processQueues.length, opensFreshProcess ? 2 : 1); assert.equal(closeCount, opensFreshProcess ? 1 : 0); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 35131040e95ce..5f8573201ef23 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7150,8 +7150,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.gen(function* () { const nativeThreadId = input.context.input.providerThread.nativeThreadRef?.nativeId ?? null; + const liveQuery = yield* Ref.get(queryContext); if (nativeThreadId !== null) { - if (input.status !== "completed") { + if (input.status !== "completed" && liveQuery?.signedOut !== true) { yield* clearPendingBackgroundTasksForNativeThread(nativeThreadId); yield* clearNativeThreadTaskIdSet( wakeEligibleBackgroundTasksByNativeThread, @@ -7184,7 +7185,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId); const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId); const terminalReason = input.result?.terminal_reason; - const liveQuery = yield* Ref.get(queryContext); if ( nativeThreadId !== null && goal?.status === "active" &&