From 7cc3627f2e73944540e45f875b8d5fccc536f5f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:07:35 +0000 Subject: [PATCH 1/8] test(server): reproduce a completed Claude subagent not reopening on an idle self-resume (#16610) Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b231fbfca9d9..766d83c718da 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8364,6 +8364,106 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // Claude reports a background subagent `completed` when its turn ends while + // its own background shell still runs, then resumes it with a new + // task_started for the same task and launch tool_use_id while the root is + // idle. That start is the only signal it is running again. + it.effect("reopens a completed background subagent that resumes while the root is idle", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-self-resume"; + const TOOL_USE_ID = "toolu-self-resume"; + const RESUMED_BASH = "toolu-self-resume-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const startTurn = (ordinal: number, continuation: boolean) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-self-resume-${ordinal}`), + text: continuation ? "Background task completed." : "Launch a background agent.", + attachments: [], + providerTurnOrdinal: ordinal, + ...(continuation + ? { + messageCreatedBy: "agent" as const, + messageCreationSource: "provider" as const, + } + : {}), + }), + ); + const latestSubagent = () => + harness.events.findLast( + (event): event is Extract => + event.type === "subagent.updated", + )?.subagent; + + yield* startTurn(1, false); + yield* harness.offerAndWait( + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016101", + }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016102", result: "LAUNCHED" }), + ); + yield* Queue.take(harness.terminalReceipts); + + // The interim completion wakes the parent, which answers and goes idle. + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "WAITING", + uuid: "00000000-0000-4000-8000-000000016103", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(2, true); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016104", result: "Noted." }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal(latestSubagent()?.status, "completed"); + + // The subagent's own shell finishes and Claude resumes it. + yield* harness.offerAndWait( + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016105", + }), + ); + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016106", + text: "Resuming.", + bashToolUseId: RESUMED_BASH, + })) { + yield* harness.offerAndWait(frame); + } + + const resumed = latestSubagent(); + assert.equal(resumed?.status, "running"); + assert.isNull(resumed?.completedAt); + const routing = subagentRouting(harness.events, [RESUMED_BASH]); + assert.deepEqual( + [...(routing.toolThreadIds.get(RESUMED_BASH) ?? [])], + [routing.childThreadId], + ); + assert.equal(harness.continuationRequests.length, 1); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { From 97f4d6cf3b9c5067b757ae2d47f47b4fe8fd9baf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:20:50 +0000 Subject: [PATCH 2/8] fix(server): a completed Claude subagent that resumes while the root is idle goes live again (#16610) Claude reports a background subagent completed when its turn ends with its own shell still running, then resumes it with a task_started under the original launch call while the root turn is idle. That start was only buffered as wake evidence, so the card stayed Finished and every child frame was held behind it. Apply that exact resume (known terminal subagent, same launch tool_use_id) against the settled turn, keeping the subagent's own run. The test now waits on an idle roster update as an ordered event barrier, so it fails on the status assertion without the fix instead of reading events before they are delivered. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 27 ++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 31 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 766d83c718da..2541de6dbe7e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8429,6 +8429,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.take(harness.terminalReceipts); assert.equal(latestSubagent()?.status, "completed"); + const threadUpdates = () => + harness.events.filter((event) => event.type === "provider_thread.updated").length; + const threadUpdatesBeforeResume = threadUpdates(); // The subagent's own shell finishes and Claude resumes it. yield* harness.offerAndWait( @@ -8446,6 +8449,17 @@ describe("ClaudeAdapterV2 background wake turns", () => { })) { yield* harness.offerAndWait(frame); } + // An empty roster emits a thread update even while idle; events are + // ordered, so once it is recorded every earlier event is too. + const barrier = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000016108", + session_id: WAKE_NATIVE_SESSION, + }); + yield* harness.offerAndWait(barrier); + yield* harness.until(() => threadUpdates() > threadUpdatesBeforeResume); const resumed = latestSubagent(); assert.equal(resumed?.status, "running"); @@ -8456,6 +8470,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { [routing.childThreadId], ); assert.equal(harness.continuationRequests.length, 1); + assert.equal(resumed?.runId, "run-attempt-claude-self-resume-1"); + + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "DONE", + uuid: "00000000-0000-4000-8000-000000016107", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.until(() => latestSubagent()?.result === "DONE"); + assert.equal(latestSubagent()?.status, "completed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 49b344961b3f..552a4a480f80 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5557,6 +5557,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( "running" | "completed" | "failed" | "cancelled" >; readonly reopen?: boolean; + // A resume while the root is idle has no run of its own to adopt it. + readonly keepRun?: boolean; readonly nativeOutcome?: Extract< SDKMessage, { readonly type: "system"; readonly subtype: "task_notification" } @@ -5679,6 +5681,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), status: input.status, ...(input.reopen === true && + input.keepRun !== true && input.status === "running" && existingSubagent !== undefined ? { runId: input.context.input.runId } @@ -7531,6 +7534,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* Ref.get(activeTurn)) ?? (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { + // A completed subagent that Claude resumes on its own re-emits + // task_started under its launch call. Buffered, it would wait for a + // wake turn that never comes, and hold every frame behind it. + if ( + message.type === "system" && + message.subtype === "task_started" && + !isClaudeNonSubagentTask(message) && + message.tool_use_id !== undefined + ) { + const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id); + const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); + if ( + registered !== undefined && + settled !== undefined && + registered.task.status !== "running" && + registered.runToolUseId === message.tool_use_id + ) { + yield* updateClaudeSubagentNode({ + context: settled, + taskId: message.task_id, + toolUseId: message.tool_use_id, + status: "running", + reopen: true, + keepRun: true, + }); + return; + } + } yield* applyWorkflowProgressWithoutTurn(message); // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. From 7bc4ed4982685f5364daaaf4fc61e299a911d1b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:41:01 +0000 Subject: [PATCH 3/8] fix(server): a Claude subagent that resumes itself while the root is idle shows as running again Claude reports a background subagent completed when its turn ends while its own background shell still runs, then resumes it under its launch call once that shell finishes. The launching run stopped listening at the interim completion, so events emitted while the root was idle reached no run and the card stayed Finished. Such a resume now requests a continuation, as a notification does, and because Claude does not wake the root for it, the continuation settles its root turn at once instead of holding it busy. Replaces the idle reopen from the previous commit, whose events were never persisted because no run subscribed to them. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 182 ++++++++++++------ .../Adapters/ClaudeAdapterV2.ts | 89 ++++----- 2 files changed, 164 insertions(+), 107 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 2541de6dbe7e..3135b5364799 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8365,17 +8365,50 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); // Claude reports a background subagent `completed` when its turn ends while - // its own background shell still runs, then resumes it with a new - // task_started for the same task and launch tool_use_id while the root is - // idle. That start is the only signal it is running again. + // its own background shell still runs. When that shell finishes, Claude + // resumes the subagent with a new task_started for the same task and launch + // tool_use_id while the root is idle. Frame order mirrors a live capture. it.effect("reopens a completed background subagent that resumes while the root is idle", () => Effect.scoped( Effect.gen(function* () { - const TASK_ID = "task-self-resume"; - const TOOL_USE_ID = "toolu-self-resume"; - const RESUMED_BASH = "toolu-self-resume-bash"; + const TASK_ID = "ae3f32459af762a5f"; + const LAUNCH = "toolu_01LcidmwEK1Y6GC6LpX3kvxX"; + const OWN_SHELL = "toolu_01QAahgijM2KRBBSkWxnui4g"; + const RESUMED_BASH = "toolu_01CkA1juicPNCtQrqKL8h6Rz"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + let sequence = 0; + const system = (frame: object) => + claudeSdkFrame({ + type: "system", + ...frame, + uuid: `00000000-0000-4000-8000-0000000162${String(++sequence).padStart(2, "0")}`, + session_id: WAKE_NATIVE_SESSION, + }); + const agentEntry = (runId: string) => ({ + task_id: TASK_ID, + run_id: runId, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Audit recent commits", + }); + const shellEntry = (taskId: string) => ({ + task_id: taskId, + run_id: `run-${taskId}`, + task_type: "local_bash", + description: "Sleep", + parent_task_id: TASK_ID, + }); + const roster = (tasks: ReadonlyArray) => + system({ subtype: "background_tasks_changed", tasks }); + const shellStarted = (taskId: string, toolUseId: string) => + system({ + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + task_type: "local_bash", + description: "Sleep", + }); const startTurn = (ordinal: number, continuation: boolean) => harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -8394,95 +8427,132 @@ describe("ClaudeAdapterV2 background wake turns", () => { : {}), }), ); - const latestSubagent = () => - harness.events.findLast( - (event): event is Extract => - event.type === "subagent.updated", - )?.subagent; + const subagentUpdates = () => + harness.events.flatMap((event) => + event.type === "subagent.updated" ? [event.subagent] : [], + ); yield* startTurn(1, false); - yield* harness.offerAndWait( + for (const frame of [ + roster([agentEntry("run-1")]), makeSubagentTaskStartedFrame({ taskId: TASK_ID, - toolUseId: TOOL_USE_ID, + toolUseId: LAUNCH, uuid: "00000000-0000-4000-8000-000000016101", }), - ); - yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016102", result: "LAUNCHED" }), - ); + ]) { + yield* harness.offerAndWait(frame); + } yield* Queue.take(harness.terminalReceipts); - // The interim completion wakes the parent, which answers and goes idle. - yield* harness.offerAndWait( + // The subagent starts its own background shell, ends its turn, and + // Claude reports it completed; the parent wakes, answers, goes idle. + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016103", + bashToolUseId: OWN_SHELL, + }), + roster([agentEntry("run-1"), shellEntry("b455x9pq5")]), + shellStarted("b455x9pq5", OWN_SHELL), + makeSubagentToolResultFrame({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016104", + toolUseId: OWN_SHELL, + }), makeSubagentNotificationFrame({ taskId: TASK_ID, - toolUseId: TOOL_USE_ID, + toolUseId: LAUNCH, summary: "WAITING", - uuid: "00000000-0000-4000-8000-000000016103", + uuid: "00000000-0000-4000-8000-000000016105", }), - ); + roster([shellEntry("b455x9pq5")]), + wakeTurnInit, + ]) { + yield* harness.offerAndWait(frame); + } yield* Queue.take(harness.continuationReceipts); yield* startTurn(2, true); yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016104", result: "Noted." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016106", result: "Noted." }), ); yield* Queue.take(harness.terminalReceipts); - assert.equal(latestSubagent()?.status, "completed"); - const threadUpdates = () => - harness.events.filter((event) => event.type === "provider_thread.updated").length; - const threadUpdatesBeforeResume = threadUpdates(); - // The subagent's own shell finishes and Claude resumes it. - yield* harness.offerAndWait( + // Its shell finishes and Claude resumes it under a new native run. + for (const frame of [ + system({ + subtype: "task_notification", + task_id: "b455x9pq5", + tool_use_id: OWN_SHELL, + status: "completed", + summary: "Background command completed (exit code 0)", + output_file: "/tmp/b455x9pq5.output", + }), + roster([]), + roster([agentEntry("run-2")]), makeSubagentTaskStartedFrame({ taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016105", + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016107", }), - ); - for (const frame of makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016106", - text: "Resuming.", - bashToolUseId: RESUMED_BASH, - })) { + ]) { yield* harness.offerAndWait(frame); } - // An empty roster emits a thread update even while idle; events are - // ordered, so once it is recorded every earlier event is too. - const barrier = claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [], - uuid: "00000000-0000-4000-8000-000000016108", - session_id: WAKE_NATIVE_SESSION, - }); - yield* harness.offerAndWait(barrier); - yield* harness.until(() => threadUpdates() > threadUpdatesBeforeResume); + // Events reach the app only through a run, and the launching run ended + // with the interim completion. + assert.equal(harness.continuationRequests.length, 2); - const resumed = latestSubagent(); + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(3, true); + // Claude does not wake the root for this, so the root must not stay busy. + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + yield* startTurn(4, false); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016110", result: "PONG" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016108", + bashToolUseId: RESUMED_BASH, + }), + shellStarted("beyhxkdpc", RESUMED_BASH), + roster([agentEntry("run-2"), shellEntry("beyhxkdpc")]), + ]) { + yield* harness.offerAndWait(frame); + } + // Events are ordered: once this roster is recorded, so is every earlier event. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.some( + (task) => task.taskId === "beyhxkdpc", + ) === true, + ), + ); + const resumed = subagentUpdates().at(-1); assert.equal(resumed?.status, "running"); assert.isNull(resumed?.completedAt); + assert.equal(resumed?.runId, "run-attempt-claude-self-resume-3"); const routing = subagentRouting(harness.events, [RESUMED_BASH]); assert.deepEqual( [...(routing.toolThreadIds.get(RESUMED_BASH) ?? [])], [routing.childThreadId], ); - assert.equal(harness.continuationRequests.length, 1); - assert.equal(resumed?.runId, "run-attempt-claude-self-resume-1"); yield* harness.offerAndWait( makeSubagentNotificationFrame({ taskId: TASK_ID, - toolUseId: TOOL_USE_ID, + toolUseId: LAUNCH, summary: "DONE", - uuid: "00000000-0000-4000-8000-000000016107", + uuid: "00000000-0000-4000-8000-000000016109", }), ); - yield* Queue.take(harness.continuationReceipts); - yield* harness.until(() => latestSubagent()?.result === "DONE"); - assert.equal(latestSubagent()?.status, "completed"); + yield* harness.until(() => subagentUpdates().at(-1)?.result === "DONE"); + assert.equal(subagentUpdates().at(-1)?.status, "completed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 552a4a480f80..fac136b2613b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5557,8 +5557,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( "running" | "completed" | "failed" | "cancelled" >; readonly reopen?: boolean; - // A resume while the root is idle has no run of its own to adopt it. - readonly keepRun?: boolean; readonly nativeOutcome?: Extract< SDKMessage, { readonly type: "system"; readonly subtype: "task_notification" } @@ -5681,7 +5679,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), status: input.status, ...(input.reopen === true && - input.keepRun !== true && input.status === "running" && existingSubagent !== undefined ? { runId: input.context.input.runId } @@ -7116,31 +7113,41 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.type === "system" && message.subtype === "task_started" && (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id); + // A completed subagent that Claude resumes on its own (under its launch + // call, unlike SendMessage) has no live run left to show it while the + // root is idle; only a continuation does. + let isResumedSubagentTaskStarted = false; if ( isKnownSubagentTaskStarted && message.type === "system" && message.subtype === "task_started" ) { const now = yield* DateTime.now; - yield* Ref.update(sessionSubagentsByTaskId, (current) => { - const registered = current.get(message.task_id); - if (registered === undefined || registered.task.status === "running") { - return current; - } - const { progress: _staleProgress, ...priorTask } = registered.task; - return new Map(current).set(message.task_id, { - ...registered, - confirmedOutcome: undefined, - task: { - ...priorTask, - status: "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - }); - }); + isResumedSubagentTaskStarted = yield* Ref.modify( + sessionSubagentsByTaskId, + (current) => { + const registered = current.get(message.task_id); + if (registered === undefined || registered.task.status === "running") { + return [false, current] as const; + } + const { progress: _staleProgress, ...priorTask } = registered.task; + return [ + registered.runToolUseId === message.tool_use_id, + new Map(current).set(message.task_id, { + ...registered, + confirmedOutcome: undefined, + task: { + ...priorTask, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }), + ] as const; + }, + ); } const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = @@ -7216,6 +7223,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( parentToolUseIdFromSdkMessage(message) === null; if ( !isPendingSubagentNotification && + !isResumedSubagentTaskStarted && !isNativeOpaqueWakeFrame && !isWakeTurnStart && message.type !== "result" @@ -7534,34 +7542,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* Ref.get(activeTurn)) ?? (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { - // A completed subagent that Claude resumes on its own re-emits - // task_started under its launch call. Buffered, it would wait for a - // wake turn that never comes, and hold every frame behind it. - if ( - message.type === "system" && - message.subtype === "task_started" && - !isClaudeNonSubagentTask(message) && - message.tool_use_id !== undefined - ) { - const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id); - const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); - if ( - registered !== undefined && - settled !== undefined && - registered.task.status !== "running" && - registered.runToolUseId === message.tool_use_id - ) { - yield* updateClaudeSubagentNode({ - context: settled, - taskId: message.task_id, - toolUseId: message.tool_use_id, - status: "running", - reopen: true, - keepRun: true, - }); - return; - } - } yield* applyWorkflowProgressWithoutTurn(message); // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. @@ -9715,7 +9695,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } - if (hasOpaqueTaskNotification && !context.nativeWakeStarted) { + // Only a notification makes Claude wake the root; a subagent + // resuming on its own does not, and must not hold the root busy. + const canWakeRoot = drained.some( + (entry) => + (entry.type === "system" && entry.subtype === "task_notification") || + isClaudeRootTurnFrame(entry), + ); + if ((hasOpaqueTaskNotification || !canWakeRoot) && !context.nativeWakeStarted) { const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); } else if (!context.nativeWakeStarted) { From 1f20a8caa2c6f9083c0f2d0a454a265ec20d654c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 19:50:58 +0000 Subject: [PATCH 4/8] fix(server): drop the unneeded idle-root settle gate from the subagent self-resume fix The owned shell's task_notification already settles the root continuation. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index fac136b2613b..08783d61b7ce 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9695,14 +9695,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } - // Only a notification makes Claude wake the root; a subagent - // resuming on its own does not, and must not hold the root busy. - const canWakeRoot = drained.some( - (entry) => - (entry.type === "system" && entry.subtype === "task_notification") || - isClaudeRootTurnFrame(entry), - ); - if ((hasOpaqueTaskNotification || !canWakeRoot) && !context.nativeWakeStarted) { + if (hasOpaqueTaskNotification && !context.nativeWakeStarted) { const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); } else if (!context.nativeWakeStarted) { From d6a049d0bd25ebd15c415ea76f015a17688cad0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 20:14:00 +0000 Subject: [PATCH 5/8] test(server): preserve the Claude self-resume runtime scenario --- scripts/fixtures/issue-16610/scenario.json | 6 ++++++ scripts/fixtures/issue-16610/seed.js | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 scripts/fixtures/issue-16610/scenario.json create mode 100644 scripts/fixtures/issue-16610/seed.js diff --git a/scripts/fixtures/issue-16610/scenario.json b/scripts/fixtures/issue-16610/scenario.json new file mode 100644 index 000000000000..97e133fd332e --- /dev/null +++ b/scripts/fixtures/issue-16610/scenario.json @@ -0,0 +1,6 @@ +{ + "provider": "claudeAgent", + "model": "claude-opus-5-5", + "effort": "medium", + "prompt": "Call the Agent tool once: subagent_type \"general-purpose\", run_in_background true, description \"Repro self-resume\", prompt: \"Run Bash with run_in_background true: python3 -c 'import time; time.sleep(30)'. Then end your turn with exactly: WAITING. When the background command's completion notification arrives, make 4 Bash calls one after another, each running python3 -c 'import time; time.sleep(15)', then reply with exactly: DONE_SELF\". After the Agent call returns, reply with exactly LAUNCHED and stop." +} diff --git a/scripts/fixtures/issue-16610/seed.js b/scripts/fixtures/issue-16610/seed.js new file mode 100644 index 000000000000..c5189f37f519 --- /dev/null +++ b/scripts/fixtures/issue-16610/seed.js @@ -0,0 +1,20 @@ +// Run with Node and evaluate its output in an isolated app's new-thread draft, +// with the scenario's Claude model selected. Submit through the normal composer; +// this preserves the real SDK lifecycle instead of seeding projection rows. +import { readFileSync } from "node:fs"; + +const scenario = JSON.parse(readFileSync(new URL("./scenario.json", import.meta.url), "utf8")); + +console.log(`(() => { + if (!location.pathname.startsWith('/draft/')) { + throw new Error('Open a new thread draft in an isolated development app first.'); + } + const composer = document.querySelector('[role="textbox"][aria-label="Message"]'); + if (!(composer instanceof HTMLElement) || !composer.isContentEditable) { + throw new Error('The message composer is unavailable.'); + } + composer.focus(); + document.execCommand('selectAll', false); + document.execCommand('insertText', false, ${JSON.stringify(scenario.prompt)}); + return { seeded: composer.innerText === ${JSON.stringify(scenario.prompt)} }; +})()`); From acb547d390ad9fec82bcb1ccb5b1b691dd18d358 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 20:32:21 +0000 Subject: [PATCH 6/8] chore: follow namespace imports in the self-resume fixture --- scripts/fixtures/issue-16610/seed.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/fixtures/issue-16610/seed.js b/scripts/fixtures/issue-16610/seed.js index c5189f37f519..40f4f0dcb072 100644 --- a/scripts/fixtures/issue-16610/seed.js +++ b/scripts/fixtures/issue-16610/seed.js @@ -1,9 +1,11 @@ // Run with Node and evaluate its output in an isolated app's new-thread draft, // with the scenario's Claude model selected. Submit through the normal composer; // this preserves the real SDK lifecycle instead of seeding projection rows. -import { readFileSync } from "node:fs"; +import * as NodeFS from "node:fs"; -const scenario = JSON.parse(readFileSync(new URL("./scenario.json", import.meta.url), "utf8")); +const scenario = JSON.parse( + NodeFS.readFileSync(new URL("./scenario.json", import.meta.url), "utf8"), +); console.log(`(() => { if (!location.pathname.startsWith('/draft/')) { From b99b2daa5ec24ffb3727cd4c04859a34bc774c59 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 20:33:34 +0000 Subject: [PATCH 7/8] chore: register the self-resume fixture entry point --- knip.jsonc | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.jsonc b/knip.jsonc index babc22932ca0..c4d5875757d9 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -24,6 +24,7 @@ "entry": [ // These fixture scripts are injected into a browser page by path. "fixtures/issue-16534/*.js", + "fixtures/issue-16610/seed.js", "knip-schemas.ts", "mobile-native-client.ts", "setup-worktree.ts", From 0db46bf4f2f052e44e9adc63a68fdffd47c77552 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:19:08 +0000 Subject: [PATCH 8/8] test(server): provide crypto to the self-resume test and drop the live fixture The live-model scenario fixture is not part of the fix; the adapter test reproduces the captured frame order. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeAdapterV2.test.ts | 7 +++++- scripts/fixtures/issue-16610/scenario.json | 6 ----- scripts/fixtures/issue-16610/seed.js | 22 ------------------- 3 files changed, 6 insertions(+), 29 deletions(-) delete mode 100644 scripts/fixtures/issue-16610/scenario.json delete mode 100644 scripts/fixtures/issue-16610/seed.js diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 34e9244dd790..a3d9e37e2019 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -14038,7 +14038,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(subagentUpdates().at(-1)?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), diff --git a/scripts/fixtures/issue-16610/scenario.json b/scripts/fixtures/issue-16610/scenario.json deleted file mode 100644 index 97e133fd332e..000000000000 --- a/scripts/fixtures/issue-16610/scenario.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "provider": "claudeAgent", - "model": "claude-opus-5-5", - "effort": "medium", - "prompt": "Call the Agent tool once: subagent_type \"general-purpose\", run_in_background true, description \"Repro self-resume\", prompt: \"Run Bash with run_in_background true: python3 -c 'import time; time.sleep(30)'. Then end your turn with exactly: WAITING. When the background command's completion notification arrives, make 4 Bash calls one after another, each running python3 -c 'import time; time.sleep(15)', then reply with exactly: DONE_SELF\". After the Agent call returns, reply with exactly LAUNCHED and stop." -} diff --git a/scripts/fixtures/issue-16610/seed.js b/scripts/fixtures/issue-16610/seed.js deleted file mode 100644 index 40f4f0dcb072..000000000000 --- a/scripts/fixtures/issue-16610/seed.js +++ /dev/null @@ -1,22 +0,0 @@ -// Run with Node and evaluate its output in an isolated app's new-thread draft, -// with the scenario's Claude model selected. Submit through the normal composer; -// this preserves the real SDK lifecycle instead of seeding projection rows. -import * as NodeFS from "node:fs"; - -const scenario = JSON.parse( - NodeFS.readFileSync(new URL("./scenario.json", import.meta.url), "utf8"), -); - -console.log(`(() => { - if (!location.pathname.startsWith('/draft/')) { - throw new Error('Open a new thread draft in an isolated development app first.'); - } - const composer = document.querySelector('[role="textbox"][aria-label="Message"]'); - if (!(composer instanceof HTMLElement) || !composer.isContentEditable) { - throw new Error('The message composer is unavailable.'); - } - composer.focus(); - document.execCommand('selectAll', false); - document.execCommand('insertText', false, ${JSON.stringify(scenario.prompt)}); - return { seeded: composer.innerText === ${JSON.stringify(scenario.prompt)} }; -})()`);