From e0e4160e92d8a2de7c1426bc519a234f3508872f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:47:27 +0000 Subject: [PATCH 1/2] fix(server): delegated tasks wait for a background subagent's wake turn A background Agent subagent's row completed at its task_notification, before the child's wake run existed, so the delegated task settled on the previous turn's interim text. Hold the subagent on the provider thread roster while its notification waits in the wake buffer, published before the row completes. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 17 +++++++ .../Adapters/ClaudeAdapterV2.ts | 47 +++++++++++++++---- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 83ab1f22263c0..9dfeef4326a3c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -16207,6 +16207,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, "completed", ); + // The row completes before the wake run exists; the roster holds the subagent until then. + const holdsTask = (event: ProviderAdapter.ProviderAdapterV2Event | undefined) => + event?.type === "provider_thread.updated" && + (event.providerThread.pendingBackgroundTasks ?? []).some( + (task) => task.taskId === TASK_ID && task.kind === "subagent", + ); + const firstCompleted = harness.events.findIndex( + (event) => event.type === "subagent.updated" && event.subagent.status === "completed", + ); + assert.isTrue( + holdsTask( + harness.events + .slice(0, firstCompleted) + .findLast((event) => event.type === "provider_thread.updated"), + ), + ); + assert.isFalse(holdsTask(providerThreadRosterEvents(harness.events).at(-1))); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 392f9d49ac58a..76c548e64ae00 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6080,6 +6080,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) tasks.push(task); } + // A background subagent's row completes at its notification, before its wake run exists. + const subagents = yield* Ref.get(sessionSubagentsByTaskId); + const nested = yield* Ref.get(nestedSubagentTaskIds); + for (const entry of (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []) { + if (entry.type !== "system" || entry.subtype !== "task_notification") continue; + const registered = subagents.get(entry.task_id); + if ( + registered === undefined || + nested.has(entry.task_id) || + tasks.some((task) => task.taskId === entry.task_id) + ) + continue; + const description = registered.task.title?.trim(); + tasks.push({ + taskId: entry.task_id, + kind: "subagent", + ...(description ? { description } : {}), + childThreadId: registered.childThreadId, + }); + } return tasks; }); @@ -9723,22 +9743,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Buffer wake evidence before clearing the task from the roster. if (message.type === "system" && message.subtype === "task_notification") { yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message }); - yield* applyBackgroundTaskRosterMessage({ + const rosterEmitted = yield* applyBackgroundTaskRosterMessage({ nativeThreadId: liveQuery.nativeThreadId, message, activeContext: null, }); const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id); const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); - const hasBufferedStart = - (yield* Ref.get(wakeBuffers)) - .get(liveQuery.nativeThreadId) - ?.messages.some( - (entry) => - entry.type === "system" && - entry.subtype === "task_started" && - entry.task_id === message.task_id, - ) ?? false; + const bufferedMessages = + (yield* Ref.get(wakeBuffers)).get(liveQuery.nativeThreadId)?.messages ?? []; + const hasBufferedStart = bufferedMessages.some( + (entry) => + entry.type === "system" && + entry.subtype === "task_started" && + entry.task_id === message.task_id, + ); + // Publish the owed wake before the row completes, so a delegated task stays open. + if ( + !rosterEmitted && + registered !== undefined && + bufferedMessages.includes(message) + ) { + yield* emitRosterUpdate(liveQuery.nativeThreadId, null); + } const canApplyOutcome = subagentStopOutcomes.has(message.task_id) || (!hasBufferedStart && registered?.task.workflow === undefined); From 89432d3be02073cfdd8b7be60b113d056cb63ee6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 17:01:12 +0000 Subject: [PATCH 2/2] test(server): background subagent replay expects the wake hold The replay fixture asserted Claude never publishes pending background tasks, which the wake-turn hold now does on purpose. It now asserts the subagent stays pending until its wake run starts and the roster ends empty. Co-Authored-By: Claude Opus 5.5 --- .../output.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts index 0b9455c830d01..97deb23f0198b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts @@ -66,16 +66,34 @@ export function assertClaudeBackgroundSubagentAfterRootOutput( (message) => message.id === projection.runs[1]?.userMessageId, ); assert.equal(wakeMessage?.text, "SUB_FINAL_REPORT"); - assert.isFalse( - result.domainEvents.some( - (event) => - event.type === "provider-thread.updated" && - (event.payload.pendingBackgroundTasks?.length ?? 0) > 0, - ), - ); if (subagent?.childThreadId == null) { throw new Error("The background subagent is missing its child thread."); } + const rosters = result.domainEvents.flatMap((event, index) => + event.type === "provider-thread.updated" + ? [{ index, tasks: event.payload.pendingBackgroundTasks ?? [] }] + : [], + ); + const continuationStartIndex = result.domainEvents.findIndex( + (event) => + event.type === "run.updated" && + event.payload.id === projection.runs[1]?.id && + event.payload.status === "starting", + ); + assert.isAtLeast(continuationStartIndex, 0); + for (const roster of rosters) { + for (const task of roster.tasks) { + assert.equal( + task.kind === "subagent" ? task.childThreadId : undefined, + subagent.childThreadId, + ); + } + } + assert.isTrue( + rosters.some((roster) => roster.index < continuationStartIndex && roster.tasks.length > 0), + "the subagent stays pending until its wake run starts", + ); + assert.lengthOf(rosters.at(-1)?.tasks ?? [], 0); assert.deepEqual(backgroundNotifications(projection), [ { summary: 'Subagent "Background subagent test" finished', @@ -96,13 +114,6 @@ export function assertClaudeBackgroundSubagentAfterRootOutput( `subagent command ${command} must be in the child thread`, ); } - const continuationStartIndex = result.domainEvents.findIndex( - (event) => - event.type === "run.updated" && - event.payload.id === projection.runs[1]?.id && - event.payload.status === "starting", - ); - assert.isAtLeast(continuationStartIndex, 0); const assertStoredWhileIdle = ( label: string, matches: (event: OrchestrationV2DomainEvent) => boolean,