From 98d67c1aaafcde0b9ec501d3d75fe2680687f45b Mon Sep 17 00:00:00 2001 From: Jreevo Date: Tue, 6 Oct 2026 15:13:31 -0700 Subject: [PATCH 1/3] fix(server): a Claude /goal no longer reads as complete while a background subagent runs Claude Code skips a goal's Stop hook check while background work runs and checks again on a later turn. The adapter inferred completion from a normal turn end plus an empty roster, but the roster leaves subagents out, so a turn that ended while a background Agent ran marked the goal complete. The completion check now also requires that the live process runs no background work of the types Claude Code 2.1.292 waits on: local_agent, remote_agent, in_process_teammate and local_workflow on the subagent path (local_bash is already on the roster). Tasks of other types, such as mcp_task and monitor_ws, are exempt, as they are in Claude. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 216 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 35 ++- 2 files changed, 249 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 785c4b1c9ec19..34fe34e3f13ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8311,6 +8311,222 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + // Level entries from Claude Code 2.1.292 carry no ambient flag. + const backgroundTasksLevel = ( + uuid: string, + tasks: ReadonlyArray<{ readonly taskId: string; readonly taskType: string }>, + ) => + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: tasks.map((task) => ({ + task_id: task.taskId, + task_type: task.taskType, + description: "Background task", + })), + uuid, + session_id: WAKE_NATIVE_SESSION, + } as unknown as SDKMessage); + // Frame order captured from Claude Code 2.1.292: the Agent call, the + // level, the backgrounded task_started, its async ack, the model's reply, + // then the result while the subagent still runs. + const backgroundAgentLaunchFrames = (input: { + readonly prefix: string; + readonly taskId: string; + }) => { + const toolUseId = `toolu-${input.prefix}`; + return [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg-${input.prefix}-launch`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Agent", + input: { + description: "Implement the fix", + subagent_type: "general-purpose", + run_in_background: true, + prompt: "Implement the fix.", + }, + }, + ], + }, + parent_tool_use_id: null, + uuid: `${input.prefix}-launch`, + session_id: WAKE_NATIVE_SESSION, + }), + backgroundTasksLevel(`${input.prefix}-level`, [ + { taskId: input.taskId, taskType: "local_agent" }, + ]), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: input.taskId, + tool_use_id: toolUseId, + description: "Implement the fix", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Implement the fix.", + uuid: `${input.prefix}-task-started`, + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: [{ type: "text", text: "Async agent launched successfully." }], + }, + ], + }, + parent_tool_use_id: null, + uuid: `${input.prefix}-ack`, + session_id: WAKE_NATIVE_SESSION, + }), + ]; + }; + + it.effect("defers goal completion while a background subagent runs", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "both reviewers find no bugs"; + const taskId = "task-goal-background-subagent"; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("goal-background-attempt"), + text: `/goal ${condition}`, + attachments: [], + }), + ); + for (const frame of [ + syntheticFrame("goal-background-set", `Goal set: ${condition}`), + ...backgroundAgentLaunchFrames({ prefix: "goal-background", taskId }), + makeAssistantTextFrame({ + uuid: "goal-background-wait", + text: "Waiting for the background subagent before round 1.", + }), + makeResultFrame({ + uuid: "goal-background-result", + result: "Waiting for the background subagent before round 1.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + // Claude skips the goal check while the subagent runs. + assert.equal(goalStatuses(harness.events).at(-1)?.status, "active"); + + // The subagent ends; Claude opens a turn on its own, and the goal + // check at its end passes. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: taskId, + tool_use_id: "toolu-goal-background", + status: "completed", + output_file: `/tmp/${taskId}.output`, + summary: "Fix implemented.", + uuid: "goal-background-notification", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait(backgroundTasksLevel("goal-background-level-empty", [])); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("goal-background-wake"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "goal-background-wake-work", text: "No bugs found." }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "goal-background-wake-result", + result: "No bugs found.", + origin: { kind: "task-notification" }, + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.deepEqual(goalStatuses(harness.events).at(-1), { + objective: condition, + status: "complete", + checks: 0, + }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + + // Claude's goal check waits on agents, workflows and shells by task type, + // ambient or not, and never on mcp_task or monitor_mcp/monitor_ws tasks. + // A task with no type is treated as one Claude waits on. + it.effect.each([ + { taskType: "mcp_task", ambient: false, status: "complete" }, + { taskType: "local_agent", ambient: true, status: "active" }, + { taskType: undefined, ambient: false, status: "active" }, + ] as const)("treats a running $taskType (ambient $ambient) as goal $status", (input) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "the docs build"; + const taskId = `task-goal-${input.taskType}`; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`goal-${input.taskType}-attempt`), + text: `/goal ${condition}`, + attachments: [], + }), + ); + for (const frame of [ + syntheticFrame(`goal-${input.taskType}-set`, `Goal set: ${condition}`), + backgroundTasksLevel(`goal-${input.taskType}-level`, [ + { taskId, taskType: input.taskType ?? "local_agent" }, + ]), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Watch the docs", + ...(input.taskType === undefined ? {} : { task_type: input.taskType }), + ...(input.ambient ? { skip_transcript: true, ambient: true } : {}), + uuid: `goal-${input.taskType}-task-started`, + session_id: WAKE_NATIVE_SESSION, + }), + makeAssistantTextFrame({ uuid: `goal-${input.taskType}-work`, text: "The docs build." }), + makeResultFrame({ uuid: `goal-${input.taskType}-result`, result: "The docs build." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + assert.equal(goalStatuses(harness.events).at(-1)?.status, input.status); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); + it.effect("keeps a goal active after a command turn with no model output", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9af1c0efde4bd..1191f009af42e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1788,6 +1788,17 @@ const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< Exclude > = new Map([["local_bash", "command"]]); +// Task types whose running tasks make Claude skip a goal's Stop hook check, +// as Claude Code 2.1.292 decides it. local_bash (Monitor tool runs included) +// is the roster's; the rest run on the subagent path. mcp_task, monitor_mcp +// and monitor_ws tasks do not defer the check. +const CLAUDE_GOAL_DEFERRING_TASK_TYPES: ReadonlySet = new Set([ + "local_agent", + "remote_agent", + "in_process_teammate", + "local_workflow", +]); + function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): boolean { return typeof taskType === "string" && CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS.has(taskType); } @@ -3157,6 +3168,8 @@ export function makeClaudeAdapterV2( }>({ userTurns: new Map(), reports: new Map() }); // Subagents Claude started in the background. Only their ends wake the root. const backgroundedSubagentTaskIds = yield* Ref.make>(new Set()); + // Subagent-path tasks whose type Claude's goal check does not wait for. + const goalExemptTaskIds = yield* Ref.make>(new Set()); // Subagents another subagent started (spawn_depth above 1). Claude // reports their end to the owning subagent, so it never wakes the root. const nestedSubagentTaskIds = yield* Ref.make>(new Set()); @@ -5082,20 +5095,27 @@ export function makeClaudeAdapterV2( input.context.input.providerThread.nativeConversationHeadRef !== null; // While a goal is set, Claude ends a turn on its own only after the // goal's Stop hook passes. It defers that check while background - // work runs, and a hook that stops the turn reports another reason. + // work of the types it waits on runs (subagents stay off the roster; + // see CLAUDE_GOAL_DEFERRING_TASK_TYPES), and a hook that stops the + // turn reports another reason. // SDK mode does not report an evaluator timeout or an impossible // verdict, so those still read as complete. const goal = nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId); const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId); const terminalReason = input.result?.terminal_reason; + const liveQuery = yield* Ref.get(queryContext); if ( nativeThreadId !== null && goal?.status === "active" && goalChecked && input.status === "completed" && (terminalReason === undefined || terminalReason === "completed") && - roster.size === 0 + roster.size === 0 && + !( + liveQuery?.nativeThreadId === nativeThreadId && + (yield* liveProcessRunsBackgroundWork(liveQuery, { forGoalCheck: true })) + ) ) { goalsByNativeThread.set(nativeThreadId, { objective: goal.objective, @@ -6024,6 +6044,14 @@ export function makeClaudeAdapterV2( activeContext: context, }); } else { + if ( + message.task_type !== undefined && + !CLAUDE_GOAL_DEFERRING_TASK_TYPES.has(message.task_type) + ) { + yield* Ref.update(goalExemptTaskIds, (current) => + new Set(current).add(message.task_id), + ); + } const launch = message.tool_use_id === undefined ? undefined @@ -7011,6 +7039,7 @@ export function makeClaudeAdapterV2( // already buffered is done: the buffer outlives the process. const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, + options?: { readonly forGoalCheck?: boolean }, ) { if ( rosterForNativeThread( @@ -7021,10 +7050,12 @@ export function makeClaudeAdapterV2( return true; } const buffered = (yield* Ref.get(wakeBuffers)).get(live.nativeThreadId)?.messages ?? []; + const ignored = options?.forGoalCheck === true ? yield* Ref.get(goalExemptTaskIds) : null; for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { if ( subagent.task.status === "running" && !live.subagentsFromEarlierProcesses.has(subagent) && + !(ignored?.has(taskId) ?? false) && !buffered.some( (message) => message.type === "system" && From c7d069b60c9445a1eda106a03eb950bc30ebb2ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 06:58:29 +0000 Subject: [PATCH 2/3] test(server): simplify Claude background goal lifecycle coverage --- .../Adapters/ClaudeAdapterV2.test.ts | 292 +++++++----------- 1 file changed, 104 insertions(+), 188 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0e4e81c5fbba3..306823a6a55e8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -15560,220 +15560,136 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Level entries from Claude Code 2.1.292 carry no ambient flag. - const backgroundTasksLevel = ( - uuid: string, - tasks: ReadonlyArray<{ readonly taskId: string; readonly taskType: string }>, - ) => - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: tasks.map((task) => ({ - task_id: task.taskId, - task_type: task.taskType, - description: "Background task", - })), - uuid, - session_id: WAKE_NATIVE_SESSION, - } as unknown as SDKMessage); - // Frame order captured from Claude Code 2.1.292: the Agent call, the - // level, the backgrounded task_started, its async ack, the model's reply, - // then the result while the subagent still runs. - const backgroundAgentLaunchFrames = (input: { - readonly prefix: string; - readonly taskId: string; - }) => { - const toolUseId = `toolu-${input.prefix}`; - return [ - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: `msg-${input.prefix}-launch`, - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: "Agent", - input: { - description: "Implement the fix", - subagent_type: "general-purpose", - run_in_background: true, - prompt: "Implement the fix.", - }, - }, - ], - }, - parent_tool_use_id: null, - uuid: `${input.prefix}-launch`, - session_id: WAKE_NATIVE_SESSION, - }), - backgroundTasksLevel(`${input.prefix}-level`, [ - { taskId: input.taskId, taskType: "local_agent" }, - ]), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: input.taskId, - tool_use_id: toolUseId, - description: "Implement the fix", - subagent_type: "general-purpose", - is_backgrounded: true, - task_type: "local_agent", - prompt: "Implement the fix.", - uuid: `${input.prefix}-task-started`, - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: toolUseId, - content: [{ type: "text", text: "Async agent launched successfully." }], - }, - ], - }, - parent_tool_use_id: null, - uuid: `${input.prefix}-ack`, - session_id: WAKE_NATIVE_SESSION, - }), - ]; - }; - - it.effect("defers goal completion while a background subagent runs", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const condition = "both reviewers find no bugs"; - const taskId = "task-goal-background-subagent"; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("goal-background-attempt"), - text: `/goal ${condition}`, - attachments: [], - }), - ); - for (const frame of [ - syntheticFrame("goal-background-set", `Goal set: ${condition}`), - ...backgroundAgentLaunchFrames({ prefix: "goal-background", taskId }), - makeAssistantTextFrame({ - uuid: "goal-background-wait", - text: "Waiting for the background subagent before round 1.", - }), - makeResultFrame({ - uuid: "goal-background-result", - result: "Waiting for the background subagent before round 1.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.take(harness.terminalReceipts); - // Claude skips the goal check while the subagent runs. - assert.equal(goalStatuses(harness.events).at(-1)?.status, "active"); + it.effect( + "defers goal completion until an ambient subagent finishes and Claude checks again", + () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "the review finds no bugs"; + const taskId = "task-goal-review"; + const toolUseId = "toolu-goal-review"; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("goal-background-attempt"), + text: `/goal ${condition}`, + attachments: [], + }), + ); + for (const frame of [ + syntheticFrame("goal-background-set", `Goal set: ${condition}`), + claudeSdkFrame({ + ...makeSubagentTaskStartedFrame({ taskId, toolUseId, uuid: "goal-review-start" }), + ambient: true, + skip_transcript: true, + }), + makeAssistantTextFrame({ + uuid: "goal-background-wait", + text: "Waiting for the review.", + }), + makeResultFrame({ uuid: "goal-background-result", result: "Waiting for the review." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual(goalStatuses(harness.events).at(-1), { + objective: condition, + status: "active", + checks: 0, + }); - // The subagent ends; Claude opens a turn on its own, and the goal - // check at its end passes. - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: taskId, - tool_use_id: "toolu-goal-background", - status: "completed", - output_file: `/tmp/${taskId}.output`, - summary: "Fix implemented.", - uuid: "goal-background-notification", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.offerAndWait(backgroundTasksLevel("goal-background-level-empty", [])); - yield* harness.offerAndWait(wakeTurnInit); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("goal-background-wake"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.offerAndWait( - makeAssistantTextFrame({ uuid: "goal-background-wake-work", text: "No bugs found." }), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "goal-background-wake-result", - result: "No bugs found.", - origin: { kind: "task-notification" }, - }), - ); - yield* Queue.take(harness.terminalReceipts); - assert.deepEqual(goalStatuses(harness.events).at(-1), { - objective: condition, - status: "complete", - checks: 0, - }); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId, + toolUseId, + summary: "No bugs found.", + uuid: "goal-review-done", + }), + ); + yield* harness.offerAndWait(wakeTurnInit); + yield* Queue.take(harness.continuationReceipts); + assert.equal(goalStatuses(harness.events).at(-1)?.status, "active"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("goal-background-wake"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "goal-background-wake-work", text: "No bugs found." }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "goal-background-wake-result", + result: "No bugs found.", + origin: { kind: "task-notification" }, + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual(goalStatuses(harness.events).at(-1), { + objective: condition, + status: "complete", + checks: 0, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); - // Claude's goal check waits on agents, workflows and shells by task type, - // ambient or not, and never on mcp_task or monitor_mcp/monitor_ws tasks. - // A task with no type is treated as one Claude waits on. it.effect.each([ - { taskType: "mcp_task", ambient: false, status: "complete" }, - { taskType: "local_agent", ambient: true, status: "active" }, - { taskType: undefined, ambient: false, status: "active" }, - ] as const)("treats a running $taskType (ambient $ambient) as goal $status", (input) => + { taskType: "mcp_task", status: "complete" }, + { taskType: undefined, status: "active" }, + ] as const)("leaves the goal $status with a running $taskType task", ({ taskType, status }) => Effect.gen(function* () { const harness = yield* makeWakeHarness; const condition = "the docs build"; - const taskId = `task-goal-${input.taskType}`; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make(`goal-${input.taskType}-attempt`), + attemptId: RunAttemptId.make("goal-task-type-attempt"), text: `/goal ${condition}`, attachments: [], }), ); for (const frame of [ - syntheticFrame(`goal-${input.taskType}-set`, `Goal set: ${condition}`), - backgroundTasksLevel(`goal-${input.taskType}-level`, [ - { taskId, taskType: input.taskType ?? "local_agent" }, - ]), + syntheticFrame("goal-task-type-set", `Goal set: ${condition}`), claudeSdkFrame({ type: "system", subtype: "task_started", - task_id: taskId, + task_id: "task-goal-docs", description: "Watch the docs", - ...(input.taskType === undefined ? {} : { task_type: input.taskType }), - ...(input.ambient ? { skip_transcript: true, ambient: true } : {}), - uuid: `goal-${input.taskType}-task-started`, + ...(taskType === undefined ? {} : { task_type: taskType }), + uuid: "goal-task-type-start", session_id: WAKE_NATIVE_SESSION, }), - makeAssistantTextFrame({ uuid: `goal-${input.taskType}-work`, text: "The docs build." }), - makeResultFrame({ uuid: `goal-${input.taskType}-result`, result: "The docs build." }), + makeAssistantTextFrame({ uuid: "goal-task-type-work", text: "The docs build." }), + makeResultFrame({ uuid: "goal-task-type-result", result: "The docs build." }), ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* Queue.take(harness.terminalReceipts); - assert.equal(goalStatuses(harness.events).at(-1)?.status, input.status); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.equal(goalStatuses(harness.events).at(-1)?.status, status); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("keeps a goal active after a command turn with no model output", () => From fc8188d549728109237e0a1f612c900a0116d163 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:02:40 +0000 Subject: [PATCH 3/3] refactor(server): keep Claude goal task types with subagent state --- .../Adapters/ClaudeAdapterV2.ts | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 57a1411140e51..b3e31a9b9c5db 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2076,10 +2076,8 @@ const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< Exclude > = new Map([["local_bash", "command"]]); -// Task types whose running tasks make Claude skip a goal's Stop hook check, -// as Claude Code 2.1.292 decides it. local_bash (Monitor tool runs included) -// is the roster's; the rest run on the subagent path. mcp_task, monitor_mcp -// and monitor_ws tasks do not defer the check. +// Claude Code 2.1.292 defers its goal Stop hook for these subagent types. +// local_bash tasks defer it through the opaque-task roster. const CLAUDE_GOAL_DEFERRING_TASK_TYPES: ReadonlySet = new Set([ "local_agent", "remote_agent", @@ -3086,6 +3084,7 @@ interface ActiveClaudeProviderRetry { interface ActiveClaudeSubagent { task: OrchestrationV2Subagent; + readonly nativeTaskType: string | undefined; readonly confirmedOutcome: ProviderAdapter.ProviderAdapterV2SubagentOutcome | undefined; readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly childThreadId: ThreadId; @@ -3630,8 +3629,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; }>({ userTurns: new Map(), reports: new Map() }); const backgroundedSubagentTaskIds = yield* Ref.make>(new Set()); - // Subagent-path tasks whose type Claude's goal check does not wait for. - const goalExemptTaskIds = yield* Ref.make>(new Set()); // Subagents another subagent started (spawn_depth above 1). Claude // reports their end to the owning subagent, so it never wakes the root. const nestedSubagentTaskIds = yield* Ref.make>(new Set()); @@ -4511,6 +4508,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const ids = claudeSubagentIds(resume.context, resume.taskId); const now = yield* DateTime.now; const subagent: ActiveClaudeSubagent = { + nativeTaskType: undefined, confirmedOutcome: undefined, childThread: null, task: { @@ -4992,6 +4990,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly model?: string; readonly effort?: string; readonly owner?: ActiveClaudeSubagent; + readonly nativeTaskType?: string; readonly progress?: string; readonly workflowFrame?: unknown; readonly workflowName?: string; @@ -5150,6 +5149,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies OrchestrationV2Subagent; const subagent = { task, + nativeTaskType: input.nativeTaskType ?? existingSubagent?.nativeTaskType, confirmedOutcome: input.nativeOutcome !== undefined ? confirmedSubagentOutcome(task) @@ -7439,14 +7439,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( activeContext: context, }); } else { - if ( - message.task_type !== undefined && - !CLAUDE_GOAL_DEFERRING_TASK_TYPES.has(message.task_type) - ) { - yield* Ref.update(goalExemptTaskIds, (current) => - new Set(current).add(message.task_id), - ); - } const launch = message.tool_use_id === undefined ? undefined @@ -7483,6 +7475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* updateClaudeSubagentNode({ context, taskId: message.task_id, + ...(message.task_type === undefined ? {} : { nativeTaskType: message.task_type }), ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), ...(message.prompt === undefined ? {} : { prompt: message.prompt }), ...(model === undefined ? {} : { model }), @@ -8433,12 +8426,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return true; } const buffered = (yield* Ref.get(wakeBuffers)).get(live.nativeThreadId)?.messages ?? []; - const ignored = options?.forGoalCheck === true ? yield* Ref.get(goalExemptTaskIds) : null; for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { if ( subagent.task.status === "running" && !live.subagentsFromEarlierProcesses.has(subagent) && - !(ignored?.has(taskId) ?? false) && + (options?.forGoalCheck !== true || + subagent.nativeTaskType === undefined || + CLAUDE_GOAL_DEFERRING_TASK_TYPES.has(subagent.nativeTaskType)) && !buffered.some( (message) => message.type === "system" &&