diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 18a16e2f63102..60597798c1519 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -16263,6 +16263,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 64537c7e5fa8f..a6199968335ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6216,6 +6216,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; }); @@ -9974,22 +9994,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); 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,