From c6008634a5281d8f0d5fb30e10bdce05eecd2dd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 10:16:42 +0000 Subject: [PATCH 1/3] test(server): reproduce issue 24297 wake-gap empty roster Tests reproduce issue 24297: after a background command exits while the Claude root is idle, the adapter publishes an empty background roster before the wake run starts, letting a delegated task finalize on a stale result. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0c8b2ad588acc..f49cb43a33efc 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7650,6 +7650,79 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // #24297: an empty roster lets the orchestrator finalize a delegated child on its pre-wake + // result, and a finalized task never reopens for the wake run that follows. + it.effect("keeps an exited background command on the roster until its wake run starts", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const wakeAttemptId = RunAttemptId.make("attempt-claude-wake-roster-2"); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-roster-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: wakeAttemptId, + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + + const wakeStart = harness.events.findIndex( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.runAttemptId === wakeAttemptId, + ); + assert.isAbove(wakeStart, -1); + const rosters = harness.events.flatMap((event, index) => + event.type === "provider_thread.updated" && + event.providerThread.id === harness.providerThread.id + ? [ + { + beforeWake: index < wakeStart, + taskIds: (event.providerThread.pendingBackgroundTasks ?? []).map( + (task) => task.taskId, + ), + }, + ] + : [], + ); + assert.deepEqual( + rosters.filter((roster) => roster.beforeWake).map((roster) => roster.taskIds), + rosters.filter((roster) => roster.beforeWake).map(() => [WAKE_TASK_ID]), + ); + assert.deepEqual(rosters.at(-1)?.taskIds, []); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each([true, false])( "carries a rejected wake rate limit into the continuation failure (event=%s)", (standaloneEvent) => From e362ada9c11af4cb6c216b150eff3c4acd9d9b7e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 10:20:57 +0000 Subject: [PATCH 2/3] fix(server): a delegated Claude child no longer finalizes before its wake run An idle background-command notification is held in the wake buffer until Claude's wake run replays it, but its roster removal was published at once. The orchestrator saw the empty roster and finalized the delegated child on its pre-wake result. Keep the internal roster cleared but defer publishing it; the wake run's (or next turn's) terminal publishes the cleared roster. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e0868918eaa4..b4484b6fc97bf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7350,6 +7350,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (!rosterChanged) { return false; } + // An idle notification held for its wake run stays published until that run (or the + // next turn) ends: an empty roster now lets a delegated child finalize on its pre-wake + // result. finalizeActiveTurn publishes the cleared roster. + if ( + input.activeContext === null && + message.type === "system" && + message.subtype === "task_notification" && + (yield* Ref.get(wakeBuffers)).get(input.nativeThreadId)?.messages.includes(message) + ) { + return true; + } const baseThread = input.activeContext?.input.providerThread ?? From 0a26c94c757e685e910fb3df4390efcebfadbc8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 10:29:44 +0000 Subject: [PATCH 3/3] fix(server): an empty Claude roster level no longer finalizes a delegated child early The previous guard only kept an exited background command published when its task_notification was buffered. Claude's empty background_tasks_changed level can arrive before or after that notification (the SDK leaves the order unspecified), and it published the empty roster straight away. While idle, a roster update now keeps any published task Claude still owes a wake run (wake-eligible or tombstoned). Only the published roster keeps it, so query replacement still sees the cleared internal roster; the wake run's finalize, an interrupted turn, or a process reset publishes it away. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 163 ++++++++++-------- .../Adapters/ClaudeAdapterV2.ts | 32 ++-- 2 files changed, 109 insertions(+), 86 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f49cb43a33efc..983cf8dfd1b2b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6255,7 +6255,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "uses task_started as an incremental roster fallback and clears on empty snapshot", + "uses task_started as an incremental roster fallback and clears the internal roster on empty snapshot", () => Effect.scoped( Effect.gen(function* () { @@ -6317,15 +6317,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - yield* Queue.offer(harness.sdkMessages, emptyRoster); - yield* harness.until(() => - providerThreadRosterEvents(harness.events).some( - (event) => - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - ); + yield* harness.offerAndWait(emptyRoster); assert.isFalse(yield* harness.hasPendingBackgroundWork); + // The exited command still owes its wake run, so the published roster keeps it. + assert.include( + ( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? [] + ).map((task) => task.taskId), + WAKE_TASK_ID, + ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7652,75 +7653,89 @@ describe("ClaudeAdapterV2 background wake turns", () => { // #24297: an empty roster lets the orchestrator finalize a delegated child on its pre-wake // result, and a finalized task never reopens for the wake run that follows. - it.effect("keeps an exited background command on the roster until its wake run starts", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const wakeAttemptId = RunAttemptId.make("attempt-claude-wake-roster-2"); + // The SDK leaves the empty level's order against the notification unspecified. + it.effect.each(["none", "before", "after"] as const)( + "keeps an exited background command on the roster until its wake run starts (empty level %s notification)", + (emptyLevel) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const wakeAttemptId = RunAttemptId.make("attempt-claude-wake-roster-2"); + const emptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000204", + session_id: WAKE_NATIVE_SESSION, + }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-roster-1"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: wakeAttemptId, - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.awaitTerminal(2); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-roster-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + if (emptyLevel === "before") yield* harness.offerAndWait(emptyRoster); + 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); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: wakeAttemptId, + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); - const wakeStart = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.runAttemptId === wakeAttemptId, - ); - assert.isAbove(wakeStart, -1); - const rosters = harness.events.flatMap((event, index) => - event.type === "provider_thread.updated" && - event.providerThread.id === harness.providerThread.id - ? [ - { - beforeWake: index < wakeStart, - taskIds: (event.providerThread.pendingBackgroundTasks ?? []).map( - (task) => task.taskId, - ), - }, - ] - : [], - ); - assert.deepEqual( - rosters.filter((roster) => roster.beforeWake).map((roster) => roster.taskIds), - rosters.filter((roster) => roster.beforeWake).map(() => [WAKE_TASK_ID]), - ); - assert.deepEqual(rosters.at(-1)?.taskIds, []); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + const wakeStart = harness.events.findIndex( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.runAttemptId === wakeAttemptId, + ); + assert.isAbove(wakeStart, -1); + const rosters = harness.events.flatMap((event, index) => + event.type === "provider_thread.updated" && + event.providerThread.id === harness.providerThread.id + ? [ + { + beforeWake: index < wakeStart, + taskIds: (event.providerThread.pendingBackgroundTasks ?? []).map( + (task) => task.taskId, + ), + }, + ] + : [], + ); + assert.deepEqual( + rosters.filter((roster) => roster.beforeWake).map((roster) => roster.taskIds), + rosters.filter((roster) => roster.beforeWake).map(() => [WAKE_TASK_ID]), + ); + assert.deepEqual(rosters.at(-1)?.taskIds, []); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ), - ), ); it.effect.each([true, false])( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b4484b6fc97bf..7310c71621ca7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4563,6 +4563,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly nativeThreadId: string; readonly providerThread: OrchestrationV2ProviderThread; readonly status?: OrchestrationV2ProviderThread["status"]; + readonly owedWakeTasks?: ReadonlyArray; }) { const roster = rosterForNativeThread( yield* Ref.get(pendingBackgroundTasksByNativeThread), @@ -4573,7 +4574,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...input.providerThread, providerSessionId: session.id, ...(input.status === undefined ? {} : { status: input.status }), - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: [ + ...claudePendingBackgroundTasksFromRoster(roster), + ...(input.owedWakeTasks ?? []).filter((task) => !roster.has(task.taskId)), + ], ...(goalsByNativeThread.has(input.nativeThreadId) ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null } : {}), @@ -7350,17 +7354,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (!rosterChanged) { return false; } - // An idle notification held for its wake run stays published until that run (or the - // next turn) ends: an empty roster now lets a delegated child finalize on its pre-wake - // result. finalizeActiveTurn publishes the cleared roster. - if ( - input.activeContext === null && - message.type === "system" && - message.subtype === "task_notification" && - (yield* Ref.get(wakeBuffers)).get(input.nativeThreadId)?.messages.includes(message) - ) { - return true; - } const baseThread = input.activeContext?.input.providerThread ?? @@ -7368,6 +7361,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (baseThread === undefined) { return true; } + // While idle, a published task Claude still owes a wake run stays published after the + // edge or level drops it (their order is unspecified): an empty roster lets a delegated + // child finalize on its pre-wake result. Only the published roster keeps it, so it never + // blocks query replacement; the wake run's finalize or a process reset publishes it away. + const owedWakeTasks: OrchestrationV2PendingBackgroundTask[] = []; + if (input.activeContext === null) { + for (const task of baseThread.pendingBackgroundTasks ?? []) { + if ( + yield* isKnownOpaqueBackgroundTaskOnNativeThread(input.nativeThreadId, task.taskId) + ) { + owedWakeTasks.push(task); + } + } + } const status = input.activeContext === null @@ -7379,6 +7386,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId: input.nativeThreadId, providerThread: baseThread, status, + owedWakeTasks, }); return true; });