diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0c8b2ad588acc..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), @@ -7650,6 +7651,93 @@ 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. + // 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); + 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), + ), + ), + ), + ); + it.effect.each([true, false])( "carries a rejected wake rate limit into the continuation failure (event=%s)", (standaloneEvent) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e0868918eaa4..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 } : {}), @@ -7357,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 @@ -7368,6 +7386,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId: input.nativeThreadId, providerThread: baseThread, status, + owedWakeTasks, }); return true; });