Skip to content

Commit 0816726

Browse files
PR Batch Testerclaude
andcommitted
fix(server): nested native subagents keep a delegated task working
A Claude native subagent's own native subagent is listed in its parent's thread but executed by the session's root run. Progress reads only added app-owned tasks executed elsewhere, so an app task finished while that nested native work still ran. Progress and finalization now include execution-owned native tasks too; the run index covers both origins. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent e1c324b commit 0816726

5 files changed

Lines changed: 51 additions & 13 deletions

File tree

‎apps/server/src/mcp/NativeSubagentDelegation.integration.test.ts‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,7 @@ it.effect(
840840
),
841841
);
842842

843-
it.effect("an app task waits for what its child's native subagent delegated, then its answer", () =>
843+
it.effect("an app task waits for its child's nested native work and tasks, then its answer", () =>
844844
Effect.scoped(
845845
Effect.gen(function* () {
846846
const h = yield* makeHarness("mixed-delegation-chain");
@@ -849,20 +849,27 @@ it.effect("an app task waits for what its child's native subagent delegated, the
849849
yield* h.startRoot(rootThreadId, "provider-session:mixed-root");
850850
const outer = yield* h.delegate(rootScope, "outer");
851851
const sentinel = yield* h.delegate(rootScope, "sentinel");
852+
const wakeSentinel = yield* h.delegate(rootScope, "wake-sentinel");
852853
const outerThreadId = outer.childThreadId!;
853854
const outerTurn = h.childTurn(outerThreadId);
854855
const child = yield* h.orchestrator.getThreadProjection(outerThreadId);
855856
const childRun = child.runs[0]!;
856857
yield* h.mcpSessions.set(sessionFor(outerThreadId, "provider-session:mixed-child"));
857858

858-
// The outer task's child runs a native subagent, which delegates in turn.
859+
// The outer task's child runs a native subagent, which delegates and runs its own.
859860
const native = h.nativeTask({
860861
name: "mixed",
861862
listing: child.thread,
862863
parentNodeId: childRun.rootNodeId!,
863864
execution: { threadId: outerThreadId, runId: childRun.id },
864865
});
865-
yield* h.project(outerTurn, [native]);
866+
const nested = h.nativeTask({
867+
name: "mixed-nested",
868+
listing: native.thread,
869+
parentNodeId: native.rootNodeId,
870+
execution: { threadId: outerThreadId, runId: childRun.id },
871+
});
872+
yield* h.project(outerTurn, [native, nested]);
866873
yield* h.mcpSessions.bindNativeCaller({
867874
threadId: outerThreadId,
868875
callId: "claude-tool:mixed",
@@ -909,8 +916,28 @@ it.effect("an app task waits for what its child's native subagent delegated, the
909916
const wakeTurn = h.childTurn(outerThreadId);
910917
assert.notStrictEqual(wakeTurn, outerTurn);
911918
assert.isNull((yield* h.listed(rootThreadId, outer.taskId)).task?.result ?? null);
912-
const settled = yield* h.taskSettles(outer.taskId);
919+
920+
// The wake ends while the nested native subagent still runs, so the answer waits for it.
921+
const wakeSentinelSettles = yield* h.taskSettles(wakeSentinel.taskId);
913922
yield* h.finishTurn(wakeTurn);
923+
yield* h.finishTurn(h.childTurn(wakeSentinel.childThreadId));
924+
yield* Fiber.join(wakeSentinelSettles);
925+
assert.equal((yield* h.listed(nested.task.threadId, nested.task.id)).task?.status, "running");
926+
const nestedWaiting = yield* h.service.taskStatus(rootScope, outer.taskId);
927+
assert.equal(nestedWaiting.workState, "waiting_for_children");
928+
assert.isNull((yield* h.listed(rootThreadId, outer.taskId)).task?.result ?? null);
929+
930+
const settled = yield* h.taskSettles(outer.taskId);
931+
yield* Queue.offer(outerTurn.events, {
932+
type: "subagent.updated",
933+
driver,
934+
subagent: {
935+
...nested.task,
936+
status: "completed",
937+
result: "Nested work done.",
938+
completedAt: nested.task.startedAt,
939+
},
940+
});
914941
yield* Fiber.join(settled);
915942
assert.equal((yield* h.listed(rootThreadId, outer.taskId)).task?.status, "completed");
916943
}),

‎apps/server/src/mcp/OrchestratorMcpService.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1609,7 +1609,7 @@ const make = Effect.gen(function* () {
16091609
.pipe(Effect.mapError(threadManagementFailure));
16101610
const childRun = delegatedTaskRun(childControls, task);
16111611
const terminalRun = latestTerminalResultRun(childControls, childRun);
1612-
// Tasks the child's native subagents delegated keep it working, as its own tasks do.
1612+
// Tasks the child's native subagents delegated or run keep it working, as its own tasks do.
16131613
const progress = delegatedTaskProgress({
16141614
...childControls,
16151615
subagents: [
@@ -1619,6 +1619,7 @@ const make = Effect.gen(function* () {
16191619
.getTasksExecutedElsewhere({
16201620
threadId: task.childThreadId,
16211621
runIds: childControls.runs.map((run) => run.id),
1622+
includeProviderNative: true,
16221623
})
16231624
.pipe(Effect.mapError(threadManagementFailure))
16241625
: []),

‎apps/server/src/orchestration-v2/Orchestrator.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export interface OrchestratorV2Shape {
413413
readonly getTasksExecutedElsewhere: (input: {
414414
readonly threadId: ThreadId;
415415
readonly runIds: ReadonlyArray<RunId>;
416+
readonly includeProviderNative?: boolean;
416417
}) => Effect.Effect<ReadonlyArray<OrchestrationV2Subagent>, OrchestratorV2Error>;
417418
readonly getThreadProjection: (
418419
threadId: ThreadId,
@@ -1286,9 +1287,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
12861287
function* <P extends Pick<OrchestrationV2ThreadProjection, "thread" | "subagents">>(
12871288
projection: P,
12881289
runIds: ReadonlyArray<RunId>,
1290+
includeProviderNative = false,
12891291
) {
12901292
const elsewhere = yield* projectionStore
1291-
.getTasksExecutedElsewhere({ threadId: projection.thread.id, runIds })
1293+
.getTasksExecutedElsewhere({
1294+
threadId: projection.thread.id,
1295+
runIds,
1296+
includeProviderNative,
1297+
})
12921298
.pipe(
12931299
Effect.mapError(
12941300
(cause) => new OrchestratorProjectionError({ threadId: projection.thread.id, cause }),
@@ -1302,7 +1308,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
13021308

13031309
/**
13041310
* A thread's progress, read with its whole task list: only a thread listing a native subagent
1305-
* can have tasks listed elsewhere that its runs execute.
1311+
* can have tasks listed elsewhere that its runs execute, including nested native subagents.
13061312
*/
13071313
const withNativeSubagentDelegations = <
13081314
P extends Pick<OrchestrationV2ThreadProjection, "thread" | "subagents" | "runs">,
@@ -1313,6 +1319,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
13131319
? withTasksExecutedElsewhere(
13141320
projection,
13151321
projection.runs.map((run) => run.id),
1322+
true,
13161323
)
13171324
: Effect.succeed(projection);
13181325

‎apps/server/src/orchestration-v2/ProjectionStore.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,11 +451,12 @@ export interface ProjectionStoreV2Shape {
451451
readonly getSubagentCount: (threadId: ThreadId) => Effect.Effect<number, ProjectionStoreV2Error>;
452452
/**
453453
* App-owned tasks the thread's runs execute but another thread lists: those a native
454-
* subagent of its session delegated.
454+
* subagent of its session delegated. Progress also needs its nested native subagents.
455455
*/
456456
readonly getTasksExecutedElsewhere: (input: {
457457
readonly threadId: ThreadId;
458458
readonly runIds: ReadonlyArray<RunId>;
459+
readonly includeProviderNative?: boolean;
459460
}) => Effect.Effect<ReadonlyArray<OrchestrationV2Subagent>, ProjectionStoreV2Error>;
460461
readonly getNextTurnItemOrdinal: (
461462
threadId: ThreadId,
@@ -5350,11 +5351,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
53505351
const getTasksExecutedElsewhere: ProjectionStoreV2Shape["getTasksExecutedElsewhere"] = ({
53515352
threadId,
53525353
runIds,
5354+
includeProviderNative = false,
53535355
}) =>
53545356
runIds.length === 0
53555357
? Effect.succeed([])
53565358
: sql<PayloadRow>`SELECT payload_json FROM orchestration_v2_projection_subagents
5357-
WHERE origin = 'app_owned' AND run_id IN ${sql.in(runIds)} AND thread_id != ${threadId}
5359+
WHERE run_id IN ${sql.in(runIds)} AND thread_id != ${threadId}
5360+
${includeProviderNative ? sql`` : sql`AND origin = 'app_owned'`}
53585361
ORDER BY subagent_id ASC`.pipe(
53595362
Effect.mapError(controlReadError(threadId)),
53605363
Effect.flatMap(decodeRows(decodeSubagentPayload, threadId)),

‎apps/server/src/persistence/Migrations/077_SubagentExecutionRunIndex.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import * as SqlClient from "effect/sql/SqlClient";
44
export default Effect.gen(function* () {
55
const sql = yield* SqlClient.SqlClient;
66

7-
// A task a native subagent delegated is listed in that subagent's thread but executed by its
8-
// session's root run, so delivery and Stop find it by run instead of by thread.
7+
// Tasks a native subagent delegated and its nested native subagents are listed in that
8+
// subagent's thread but executed by its session's root run, so delivery, Stop and progress
9+
// find them by run instead of by thread.
910
yield* sql`
10-
CREATE INDEX orchestration_v2_projection_subagents_app_owned_run_idx
11+
CREATE INDEX orchestration_v2_projection_subagents_run_idx
1112
ON orchestration_v2_projection_subagents(run_id)
12-
WHERE origin = 'app_owned'
1313
`;
1414
});

0 commit comments

Comments
 (0)