From c81f3257ccf6ce448cf7bf50e6163937d7057408 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:14:54 +0000 Subject: [PATCH 1/6] test(server): background native subagent can delegate after its parent's run settles Failing reproduction for #16808. delegateTask rejects with parent_not_active once the root run completes, even though the shell still lists a live background subagent on the caller's provider instance. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index bba4e816cacae..e634dc566560c 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1489,6 +1489,111 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("a native background subagent delegates after its parent's run settles", () => + Effect.gen(function* () { + const dispatched = yield* Ref.make>([]); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Reply with exactly OK.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + let delegated = false; + // The root turn ended, so checkpoint capture completed its run, while the + // background subagent it launched still runs and calls tools. + const settledParent = (subagents: ReadonlyArray) => { + const projection = parentProjection(subagents); + return { + ...projection, + runs: projection.runs.map((run) => ({ ...run, status: "completed" })), + } as unknown as OrchestrationV2ThreadProjection; + }; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? settledParent(delegated ? [task] : []) + : childProjection, + ), + getThreadShell: (threadId) => + Effect.succeed({ + ...liveThreadShell(threadId, { activeRunId: null }), + pendingBackgroundTasks: [ + { + taskId: "native-background-subagent", + kind: "subagent" as const, + providerInstanceId: codexInstanceId, + }, + ], + }), + isProviderInstanceEnabledForProject: () => Effect.succeed(true), + assertProviderInstanceEnabled: () => Effect.void, + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + Effect.sync(() => { + delegated = true; + }), + ), + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ServerSettings.layerTest(), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service + .delegateTask(scope, { + task: "Reply with exactly OK.", + target: { providerInstanceId: codexInstanceId, model: "gpt-5.4" }, + mode: "async", + clientRequestId: "delegate-from-background-subagent-1", + }) + .pipe(Effect.catch((error) => Effect.succeed(error))); + assert.deepInclude(result, { status: "running" }); + assert.equal((yield* Ref.get(dispatched)).length, 1); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect( "retrying an accepted driver-only delegation replays even after the project disables every candidate", () => From fac85cd6d2ab83ba59671d53ba67b27e233ad2e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:31:01 +0000 Subject: [PATCH 2/6] fix(server): a native background subagent can delegate after its parent's run settles A Claude root turn that started a background Agent ends, checkpoint capture completes its run, and the still-running subagent's delegate_task call failed with parent_not_active. Other MCP tools already accept a live caller through the shell's pending background subagent; delegation, and the orchestrator's delegated_task.request guard, still required a blocking run. Both now also accept the thread's latest run when no run is active and the shell lists a pending background subagent on that run's (the caller's) provider instance. The task joins that run's completion cohort, so its result is still delivered; stop, archive and provider ownership checks are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 1 + apps/server/src/mcp/OrchestratorMcpService.ts | 27 +- ...skCancelNativeSubagent.integration.test.ts | 474 ++++++++++++++---- .../src/orchestration-v2/Orchestrator.ts | 22 +- .../orchestrationV2PendingBackgroundWork.ts | 12 + 5 files changed, 419 insertions(+), 117 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e634dc566560c..71b955619769f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1535,6 +1535,7 @@ describe("OrchestratorMcpService provider resolution", () => { getThreadShell: (threadId) => Effect.succeed({ ...liveThreadShell(threadId, { activeRunId: null }), + latestRunId: parentRunId, pendingBackgroundTasks: [ { taskId: "native-background-subagent", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index edaa8777b422e..a24b111a90fbf 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -66,6 +66,7 @@ import { isOrchestrationV2WorkActive, } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; +import { hasPendingBackgroundSubagent } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, @@ -232,10 +233,7 @@ export const isLiveCallerShell = ( shell.archivedAt === null && shell.providerInstanceId === providerInstanceId && (shell.activeRunId !== null || - (shell.pendingBackgroundTasks?.some( - (task) => task.kind === "subagent" && task.providerInstanceId === providerInstanceId, - ) ?? - false)); + hasPendingBackgroundSubagent(shell.pendingBackgroundTasks, providerInstanceId)); /** * Threads an agent starts over MCP are `mcp:`, and agents often paste the @@ -1272,6 +1270,21 @@ const make = Effect.gen(function* () { : Effect.void; }; + // A background subagent keeps calling tools after the run that started it settles. Its + // delegations join that latest run, whose completion cohort wakes the thread once they finish. + const backgroundSubagentRun = ( + scope: McpInvocationScope, + parent: Pick, + ) => + threadManagement.getThreadShell(parent.thread.id).pipe( + Effect.mapError(threadManagementFailure), + Effect.map((shell) => + shell?.activeRunId === null && isLiveCallerShell(shell, scope.thread?.providerInstanceId) + ? parent.runs.find((run) => run.id === shell.latestRunId) + : undefined, + ), + ); + const loadReadableThread = Effect.fn("OrchestratorMcpService.loadReadableThread")(function* ( scope: McpInvocationScope, threadId: ThreadId, @@ -2144,9 +2157,9 @@ const make = Effect.gen(function* () { }), delegateTask: Effect.fn("OrchestratorMcpService.delegateTask")(function* (callerScope, input) { const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task"); - const parentRun = parent.runs - .filter(ThreadManagementService.isActiveRun) - .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + const parentRun = + ThreadManagementService.latestActiveRun(parent) ?? + (yield* backgroundSubagentRun(scope, parent)); if ( parentRun === undefined || parentRun.rootNodeId === null || diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 9c36a26045dc5..eb5c18e4a6b0b 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -13,6 +13,7 @@ import { ThreadId, TurnItemId, type OrchestrationV2DomainEvent, + type ServerProvider, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -49,6 +50,132 @@ const layerOrchestratorService = OrchestratorMcpService.layer.pipe( const driver = ProviderDriverKind.make("claudeAgent"); const instanceId = ProviderInstanceId.make("claudeAgent"); const modelSelection = { instanceId, model: "claude-test" }; +const provider: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-22T00:00:00.000Z", + models: [ + { slug: modelSelection.model, name: modelSelection.model, isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], +}; + +const makeHarness = Effect.fn(function* (name: string) { + const cwd = yield* checkpointWorkspace(name); + const turns: Array<{ + readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly events: Queue.Queue; + }> = []; + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: ClaudeProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: `native:${threadId}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + turns.push({ turn, events }); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: null, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry({ name }, layerAdapters, { + runEffectWorker: false, + }); + const layerOrchestration = Layer.merge( + layerOrchestrator, + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory))), + ), + ); + const layerTest = layerOrchestratorService.pipe( + Layer.provideMerge(layerOrchestration), + Layer.provide( + Layer.mergeAll( + layerAdapters, + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + refreshInstance: () => Effect.succeed([provider]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ServerSettings.layerTest(), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + NodeServices.layer, + ), + ), + ); + return { cwd, turns, layerTest }; +}); // A delegated Claude child ends its turn while a background native subagent it // started keeps running (#17154). @@ -56,115 +183,7 @@ const modelSelection = { instanceId, model: "claude-test" }; it.effect("task_cancel ends a delegated child's background native subagent", () => Effect.scoped( Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("task-cancel-native-subagent"); - const turns: Array<{ - readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; - readonly events: Queue.Queue; - }> = []; - const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { - instanceId, - driver, - getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: (input) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const events = yield* Queue.unbounded(); - return { - instanceId, - driver, - providerSessionId: input.providerSessionId, - providerSession: { - id: input.providerSessionId, - driver, - providerInstanceId: instanceId, - status: "ready", - cwd, - model: modelSelection.model, - capabilities: ClaudeProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }, - events: Stream.fromQueue(events), - ensureThread: ({ threadId }) => - Effect.succeed({ - id: ProviderThreadId.make(`provider-thread:${threadId}`), - driver, - providerInstanceId: instanceId, - providerSessionId: input.providerSessionId, - appThreadId: threadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: `native:${threadId}`, strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }), - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: (turn) => - Effect.gen(function* () { - turns.push({ turn, events }); - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { - id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), - providerThreadId: turn.providerThread.id, - nodeId: turn.rootNodeId, - runAttemptId: turn.attemptId, - nativeTurnRef: null, - ordinal: turn.providerTurnOrdinal, - status: "running", - startedAt: now, - completedAt: null, - }, - }); - }), - steerTurn: () => Effect.die("unused"), - interruptTurn: () => Effect.void, - respondToRuntimeRequest: () => Effect.die("unused"), - readThreadSnapshot: () => Effect.die("unused"), - rollbackThread: () => Effect.die("unused"), - forkThread: () => Effect.die("unused"), - }; - }), - }; - const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "task-cancel-native-subagent" }, - layerAdapters, - { runEffectWorker: false }, - ); - const layerOrchestration = Layer.merge( - layerOrchestrator, - ThreadManagementService.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), - ), - ), - ); - const layerTest = layerOrchestratorService.pipe( - Layer.provideMerge(layerOrchestration), - Layer.provide( - Layer.mergeAll( - layerAdapters, - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - ServerSettings.layerTest(), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - NodeServices.layer, - ), - ), - ); - + const { cwd, turns, layerTest } = yield* makeHarness("task-cancel-native-subagent"); yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; @@ -371,3 +390,240 @@ it.effect("task_cancel ends a delegated child's background native subagent", () }), ), ); + +// The root Claude turn ends while a background native subagent it started still +// runs and delegates (#16808). +it.effect("a native background subagent delegates after its parent's run completes", () => + Effect.scoped( + Effect.gen(function* () { + const { cwd, turns, layerTest } = yield* makeHarness("native-subagent-delegates"); + + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const parentThreadId = ThreadId.make("thread:native-delegate-parent"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turnRunning = () => + watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:native-delegate"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const parentRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent"), + threadId: parentThreadId, + messageId: MessageId.make("message:start-parent"), + text: "Start a background agent", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(parentRunning); + const root = turns[0]!; + const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + const rootTurn = (yield* orchestrator.getThreadProjection(parentThreadId)) + .providerTurns[0]!; + + const now = yield* DateTime.now; + const subagentId = NodeId.make("node:native-subagent"); + const native = { + threadId: parentThreadId, + runId: parentRun.id, + origin: "provider_native", + driver, + providerInstanceId: instanceId, + providerThreadId: rootTurn.providerThreadId, + childThreadId: null, + prompt: "Sleep, then delegate", + title: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + } as const; + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === parentRun.id && + event.payload.status === "waiting", + ); + for (const event of [ + { + type: "node.updated", + driver, + node: { + id: subagentId, + threadId: parentThreadId, + runId: parentRun.id, + parentNodeId: root.turn.rootNodeId, + rootNodeId: root.turn.rootNodeId, + kind: "subagent", + status: "running", + countsForRun: false, + providerThreadId: rootTurn.providerThreadId, + providerTurnId: rootTurn.id, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + type: "subagent.updated", + driver, + subagent: { + ...native, + id: subagentId, + parentNodeId: root.turn.rootNodeId, + createdBy: "agent", + nativeTaskRef: null, + model: null, + }, + }, + { + type: "turn_item.updated", + driver, + turnItem: { + ...native, + id: TurnItemId.make("turn-item:native-subagent"), + nodeId: subagentId, + providerTurnId: rootTurn.id, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "subagent", + subagentId, + }, + }, + { + type: "provider_turn.updated", + driver, + providerTurn: { ...rootTurn, status: "completed", completedAt: now }, + }, + { + type: "turn.terminal", + driver, + providerThreadId: rootTurn.providerThreadId, + providerTurnId: rootTurn.id, + runOrdinal: root.turn.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }, + ] satisfies ReadonlyArray) { + yield* Queue.offer(root.events, event); + } + yield* Fiber.join(waiting); + yield* worker.drain(); + + const settled = yield* orchestrator.getThreadShell(parentThreadId); + assert.equal(settled?.activeRunId, null); + assert.equal( + (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]?.status, + "completed", + ); + assert.equal(settled?.pendingBackgroundTasks?.[0]?.kind, "subagent"); + + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:native-delegate"), + requestNamespace: "provider-session:native-delegate", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:native-delegate", + providerInstanceId: instanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const childRunning = yield* turnRunning(); + const delegated = yield* service.delegateTask(scope, { + task: "Reply with exactly OK.", + mode: "async", + clientRequestId: "delegate-from-native-subagent", + }); + assert.equal(delegated.status, "running"); + yield* worker.drain(); + yield* Fiber.join(childRunning); + + const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (entry) => entry.origin === "app_owned", + )!; + assert.equal(task.runId, parentRun.id); + assert.equal(task.parentNodeId, parentRun.rootNodeId); + + // The settled run's cohort still claims the result to wake the parent. + const child = turns.find((entry) => entry.turn.threadId === task.childThreadId)!; + const childTurn = (yield* orchestrator.getThreadProjection(task.childThreadId!)) + .providerTurns[0]!; + const finished = yield* DateTime.now; + const childWaiting = yield* watch( + (event) => + event.type === "run.updated" && + event.threadId === task.childThreadId && + event.payload.status === "waiting", + ); + const taskCompleted = yield* watch( + (event) => + event.type === "subagent.updated" && + event.payload.id === task.id && + event.payload.status === "completed", + ); + for (const event of [ + { + type: "provider_turn.updated", + driver, + providerTurn: { ...childTurn, status: "completed", completedAt: finished }, + }, + { + type: "turn.terminal", + driver, + providerThreadId: childTurn.providerThreadId, + providerTurnId: childTurn.id, + runOrdinal: child.turn.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }, + ] satisfies ReadonlyArray) { + yield* Queue.offer(child.events, event); + } + yield* Fiber.join(childWaiting); + yield* worker.drain(); + yield* Fiber.join(taskCompleted); + yield* worker.drain(); + const after = yield* orchestrator.getThreadProjection(parentThreadId); + assert.equal( + after.subagents.find((entry) => entry.id === task.id)?.completionDelivery?.state, + "claimed", + ); + assert.deepEqual(after.runs[0]?.delegatedCompletion?.delivery?.taskIds, [task.id]); + }).pipe(Effect.provide(layerTest)); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8c8620088fa94..d47d67cb6876d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -66,6 +66,7 @@ import { import { modelSelectionsEqual } from "@t3tools/shared/model"; import { derivePendingBackgroundWork, + hasPendingBackgroundSubagent, pendingBackgroundTurnItems, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Context from "effect/Context"; @@ -6892,6 +6893,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* Ref.update(effects, (existing) => [...existing, pendingEffect]); }); + // A native background subagent keeps working, and delegating, after the latest run settles. + const backgroundSubagentOutlivesRun = (run: OrchestrationV2Run) => + projectionStore.getThreadShell(run.threadId).pipe( + Effect.map( + (shell) => + shell !== null && + shell.archivedAt === null && + shell.activeRunId === null && + shell.latestRunId === run.id && + hasPendingBackgroundSubagent(shell.pendingBackgroundTasks, run.providerInstanceId), + ), + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: run.threadId, cause }), + ), + ); + const dispatchDelegatedTaskRequest = Effect.fn("orchestrationV2.dispatch.delegatedTaskRequest")( function* ( command: Extract, @@ -6919,7 +6936,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun)) { + if ( + parentRun === undefined || + !(isBlockingRun(parentRun) || (yield* backgroundSubagentOutlivesRun(parentRun))) + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 8073bcccd0bb5..7b614fc269602 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -92,6 +92,18 @@ export function deriveBlockedOnThreadWait(input: { return hasWait; } +/** A provider instance's background subagent outlives its run and still calls tools. */ +export function hasPendingBackgroundSubagent( + tasks: ReadonlyArray | undefined, + providerInstanceId: OrchestrationV2ProviderThread["providerInstanceId"] | undefined, +): boolean { + return ( + tasks?.some( + (task) => task.kind === "subagent" && task.providerInstanceId === providerInstanceId, + ) ?? false + ); +} + export function backgroundWorkHoldsCompletion( tasks: ReadonlyArray>, ): boolean { From 5b55bf6cab615dc162e312678e794b92717b5180 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:48:14 +0000 Subject: [PATCH 3/6] chore(server): stop exporting isActiveRun now that only its module uses it Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/ThreadManagementService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe1..dac7c041b08e9 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -441,7 +441,7 @@ export class ThreadManagementService extends Context.Service< ThreadManagementServiceShape >()("t3/orchestration-v2/ThreadManagementService") {} -export function isActiveRun(run: OrchestrationV2Run): boolean { +function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || run.status === "starting" || From 6fbe5b391074c883d6ddfefb7e0f5a987ab34e5d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:27:47 +0000 Subject: [PATCH 4/6] fix(server): load the settled latest run for background subagent delegation Caller records now carry only active runs, so the fallback reads the latest run by id when a matching background subagent is still pending. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/OrchestratorMcpService.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 3beedbed904ef..9776c90ef9664 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1229,18 +1229,24 @@ const make = Effect.gen(function* () { // A background subagent keeps calling tools after the run that started it settles. Its // delegations join that latest run, whose completion cohort wakes the thread once they finish. - const backgroundSubagentRun = ( - scope: McpInvocationScope, - parent: Pick, - ) => - threadManagement.getThreadShell(parent.thread.id).pipe( - Effect.mapError(threadManagementFailure), - Effect.map((shell) => - shell?.activeRunId === null && isLiveCallerShell(shell, scope.thread?.providerInstanceId) - ? parent.runs.find((run) => run.id === shell.latestRunId) - : undefined, - ), - ); + const backgroundSubagentRun = Effect.fn("OrchestratorMcpService.backgroundSubagentRun")( + function* (scope: McpInvocationScope, threadId: ThreadId) { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + if ( + shell?.activeRunId !== null || + shell.latestRunId === null || + !isLiveCallerShell(shell, scope.thread?.providerInstanceId) + ) { + return undefined; + } + const { runs } = yield* loadThreadRecords(threadId, ["runs"], { + runIds: [shell.latestRunId], + }); + return runs[0]; + }, + ); const loadReadableThread = Effect.fn("OrchestratorMcpService.loadReadableThread")(function* ( scope: McpInvocationScope, @@ -2407,7 +2413,7 @@ const make = Effect.gen(function* () { } const parentRun = ThreadManagementService.latestActiveRun(parent) ?? - (yield* backgroundSubagentRun(scope, parent)); + (yield* backgroundSubagentRun(scope, parent.thread.id)); if ( parentRun === undefined || parentRun.rootNodeId === null || From 6db12158a4e998209af572ef6da7bcacfbd228b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:29:58 +0000 Subject: [PATCH 5/6] test(server): drop the issue number from the background delegation test comment AI-assisted; verified with the tests and tools listed above. --- .../server/src/mcp/TaskCancelNativeSubagent.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 190c1709400bd..9294c6fa486e1 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -393,7 +393,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () ); // The root Claude turn ends while a background native subagent it started still -// runs and delegates (#16808). +// runs and delegates. it.effect("a native background subagent delegates after its parent's run completes", () => Effect.scoped( Effect.gen(function* () { From 157f45bc95f7804a967592cdf0b3f1cf878acf68 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:25:51 +0000 Subject: [PATCH 6/6] fix(web): a settled turn keeps its duration when a background subagent delegates later AI-assisted; verified with the tests and tools listed above. --- .../chat/MessagesTimeline.logic.test.ts | 33 +++++++++++++++---- .../components/chat/MessagesTimeline.logic.ts | 12 +++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5d242f1efc20e..cdd4675e9528a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -32,6 +32,7 @@ import { shouldCollapseUserMessage, resolveAssistantMessageCopyState, type MessagesTimelineRow, + type TimelineLatestRun, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; @@ -4479,7 +4480,16 @@ describe("live subagents after their parent turn settles", () => { prompt: `Inspect ${id}`, result: null, }); - const rowsFor = (children: ReadonlyArray) => + const rowsFor = ( + children: ReadonlyArray, + afterReply: ReadonlyArray = [], + latestRun: TimelineLatestRun = { + runId, + status: "completed", + startedAt: DateTime.formatIso(at(0)), + completedAt: DateTime.formatIso(at(8)), + }, + ) => deriveMessagesTimelineRows({ timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: ( @@ -4519,16 +4529,12 @@ describe("live subagents after their parent turn settles", () => { text: "Both children are running.", streaming: false, }, + ...afterReply, ] satisfies OrchestrationV2TurnItem[] ).map(projected), optimisticMessages: [], }), - latestRun: { - runId, - status: "completed", - startedAt: DateTime.formatIso(at(0)), - completedAt: DateTime.formatIso(at(8)), - }, + latestRun, isWorking: false, turnDiffSummaries: [], supportsConversationRollback: false, @@ -4547,6 +4553,19 @@ describe("live subagents after their parent turn settles", () => { }, ); + it("keeps the turn's duration when a background child delegates after it settles", () => { + // The child's completion woke a later run, so this run's duration comes from its entries. + const rows = rowsFor([], [child("late", 50, "completed")], { + runId: RunId.make("wake-run"), + status: "completed", + startedAt: DateTime.formatIso(at(55)), + completedAt: DateTime.formatIso(at(58)), + }); + expect(rows.find((row) => row.kind === "turn-fold")).toMatchObject({ + label: "Worked for 8s", + }); + }); + it("keeps a launch batch visible while any member is still running", () => { const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]); expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9e9d82a11fcbd..34d52e4b47c97 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1179,8 +1179,16 @@ function deriveTurnFolds(input: { const isLatestInterruptedTurn = input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; // A turn cut short by a steer leaves trailing work entries behind its - // terminal message — take whichever ended last. - const lastEntryEnd = timelineEntryEnd(lastEntry); + // terminal message — take whichever ended last. A subagent card after it was + // spawned by background work once the turn settled, so it adds no turn time. + const lastTurnEntry = + group.entries.findLast( + (entry, index) => + index <= terminalEntryIndex || + entry.kind !== "event" || + entry.projectedItem.item.type !== "subagent", + ) ?? lastEntry; + const lastEntryEnd = timelineEntryEnd(lastTurnEntry); const elapsedMs = input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)