From 418b3b4f5916d01d8bc6e4bf436469b49a48e9e0 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:26:49 -0800 Subject: [PATCH] fix(server): Claude's wake turn after a background task no longer dies to a session release hasPendingBackgroundWork reported no work between a background task's task_notification and the first frame of the wake turn Claude opens for it, so an idle, linked-environment, or detach release could close the CLI mid-wake and lose the wake. A buffered notification that recorded a wake report now counts as pending until the wake run takes it. Fixes #37252 --- .../Adapters/ClaudeAdapterV2.test.ts | 16 +++++++++++++--- .../orchestration-v2/Adapters/ClaudeAdapterV2.ts | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4bb712aa9ffc..e42c8a84e6ec 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -15415,7 +15415,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait(wakeNotification); assert.lengthOf(harness.continuationRequests, 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + // Claude is opening its wake turn: a session release now would kill it. + assert.isTrue(yield* harness.hasPendingBackgroundWork); yield* Queue.offer(harness.sdkMessages, wakeAssistant); yield* harness.awaitContinuation(1); @@ -16305,7 +16306,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait(wakeNotification); if (emptyLevel === "after") yield* harness.offerAndWait(emptyRoster); // Query replacement reads the internal roster, which the exit already cleared. - assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.isFalse( + yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread, { + includeSubagents: true, + }), + ); + // The owed wake still holds the process against a session release. + assert.isTrue(yield* harness.hasPendingBackgroundWork); yield* harness.offerAndWait(wakeTurnInit); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -16357,6 +16364,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { rosters.at(-1)?.taskIds, emptyLevel === "sibling" ? [[secondTaskId, true]] : [], ); + // The wake run took the buffered notification, so it no longer holds the session. + if (emptyLevel !== "sibling") assert.isFalse(yield* harness.hasPendingBackgroundWork); if (emptyLevel === "sibling") { assert.isTrue(rosters.at(-1)?.pendingWork); yield* harness.offerAndWait( @@ -20966,7 +20975,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* changes.until( () => events.filter((event) => event.type === "turn.terminal").length === 2, ); - assert.isFalse(yield* hasPendingBackgroundWork); + // The model switch went through; the wake it carried over is still owed. + assert.isTrue(yield* hasPendingBackgroundWork); assert.lengthOf(continuationRequests, 0); yield* Queue.offer(secondProcess, wakeResult); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bced035fed01..cb9bfadc7a4a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -13779,13 +13779,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } const buffers = yield* Ref.get(wakeBuffers); - for (const entry of buffers.values()) { + const { reports } = yield* Ref.get(wakeReportsByNativeThread); + for (const [nativeThreadId, entry] of buffers) { + const wakeReports = reports.get(nativeThreadId); if ( entry.messages.some( (message) => message.type === "user" || message.type === "assistant" || - message.type === "result", + message.type === "result" || + // Claude opens a wake turn for this notification; closing before its first + // frame arrives kills the turn and loses the wake. + (message.type === "system" && + message.subtype === "task_notification" && + wakeReports?.has(message.task_id) === true), ) ) { return true;