Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2691,6 +2691,13 @@ interface ActiveClaudeSubagent {
readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
readonly turnItemId: OrchestrationV2TurnItem["id"];
readonly turnItemOrdinal: number;
// The parent turn the subagent's card is attributed to. A task_progress
// that lands with no active turn (the root settled while the background
// subagent keeps running) still needs these to update the card's turn
// item, so they are captured once and reused rather than read off a
// context that no longer exists.
readonly turnItemProviderThreadId: ProviderThreadId;
readonly turnItemProviderTurnId: OrchestrationV2ProviderTurn["id"];
// The tool call that started the current run: the Agent launch, then each
// SendMessage that resumes the subagent. A new one means a new prompt.
readonly runToolUseId: string | null;
Expand Down Expand Up @@ -3941,6 +3948,8 @@ export function makeClaudeAdapterV2(
resume.context,
`task:${resume.taskId}:subagent`,
),
turnItemProviderThreadId: resume.context.input.providerThread.id,
turnItemProviderTurnId: resume.context.providerTurnId,
runToolUseId: launchToolUseId,
nextChildItemOrdinal: 100,
resultItemOrdinal: null,
Expand Down Expand Up @@ -4097,6 +4106,19 @@ export function makeClaudeAdapterV2(
childRootNodeId,
turnItemId: existingSubagent?.turnItemId ?? derivedIds.turnItemId,
turnItemOrdinal,
// A reopen reattributes to the resuming run's turn (see task.runId
// above); otherwise the card keeps the turn it was first created
// under so a later out-of-context progress tick can reuse it.
turnItemProviderThreadId:
existingSubagent === undefined ||
(input.reopen === true && input.status === "running")
? input.context.input.providerThread.id
: existingSubagent.turnItemProviderThreadId,
turnItemProviderTurnId:
existingSubagent === undefined ||
(input.reopen === true && input.status === "running")
? input.context.providerTurnId
: existingSubagent.turnItemProviderTurnId,
runToolUseId:
existingSubagent === undefined
? (input.toolUseId ?? null)
Expand Down Expand Up @@ -4353,6 +4375,76 @@ export function makeClaudeAdapterV2(
}
});

// A backgrounded subagent (Agent with run_in_background) keeps
// streaming task_progress while the root turn is idle. Those frames
// carry only a point-in-time description, with nothing to usefully
// replay later, so unlike other frames they are not parked in the
// wake buffer: without this, the card shows whatever progress it had
// when the root settled for as long as the subagent keeps running.
// Hydrate the session registry that survives turn settle directly,
// reusing the turn attribution captured when the card was created.
const applyClaudeSubagentProgressWithoutActiveTurn = Effect.fnUntraced(function* (input: {
readonly message: Extract<SDKMessage, { readonly subtype: "task_progress" }>;
}) {
const progress = input.message.description.trim();
if (progress.length === 0) {
return;
}
const existingSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(
input.message.task_id,
);
// A late/out-of-order frame for a subagent that already settled
// must not resurrect its card; the same rule updateClaudeSubagentNode
// applies to task_progress under an active turn.
if (existingSubagent === undefined || existingSubagent.task.status !== "running") {
return;
}
const now = yield* DateTime.now;
const task: OrchestrationV2Subagent = {
...existingSubagent.task,
progress,
updatedAt: now,
};
const subagent: ActiveClaudeSubagent = { ...existingSubagent, task };
yield* Ref.update(sessionSubagentsByTaskId, (current) =>
new Map(current).set(input.message.task_id, subagent),
);
yield* emitProviderEvent({
type: "subagent.updated",
driver: CLAUDE_PROVIDER,
subagent: task,
});
yield* emitProviderEvent({
type: "turn_item.updated",
driver: CLAUDE_PROVIDER,
turnItem: {
id: subagent.turnItemId,
threadId: task.threadId,
runId: task.runId,
nodeId: task.id,
providerThreadId: subagent.turnItemProviderThreadId,
providerTurnId: subagent.turnItemProviderTurnId,
nativeItemRef: task.nativeTaskRef,
parentItemId: null,
ordinal: subagent.turnItemOrdinal,
status: task.status,
title: task.title,
startedAt: task.startedAt,
completedAt: task.completedAt,
updatedAt: task.updatedAt,
type: "subagent",
subagentId: task.id,
origin: task.origin,
driver: task.driver,
providerInstanceId: task.providerInstanceId,
childThreadId: task.childThreadId,
prompt: task.prompt,
progress: task.progress,
result: task.result,
},
});
});

const emitClaudePlanProjection = Effect.fnUntraced(function* (input: {
readonly context: ActiveClaudeTurnContext;
readonly nativeItemId: string;
Expand Down Expand Up @@ -5503,6 +5595,13 @@ export function makeClaudeAdapterV2(
message,
activeContext: null,
});
} else if (message.type === "system" && message.subtype === "task_progress") {
// A backgrounded subagent keeps streaming progress while the
// root is idle. task_progress is not wake evidence (it never
// proves the CLI re-engaged) and carries nothing worth
// replaying later, so it updates the card now instead of
// going through bufferWakeMessage.
yield* applyClaudeSubagentProgressWithoutActiveTurn({ message });
} else {
yield* applyBackgroundTaskRosterMessage({
nativeThreadId: liveQuery.nativeThreadId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ export function assertClaudeBackgroundSubagentAfterRootOutput(
const subagent = projection.subagents[0];
assert.equal(subagent?.status, "completed");
assert.equal(subagent?.origin, "provider_native");
// The recording's two task_progress frames both land while the root is
// idle between its result and the wake that starts run 2 (the subagent's
// own foreground Bash steps are not background work and do not wake it
// early). The card must still pick up the latest one instead of staying
// frozen on whatever it showed when the root settled.
assert.equal(subagent?.progress, "Running Sleep 3 seconds then echo SUB_DONE_2");
const subagentTurnItem = projection.turnItems.find(
(item) => item.type === "subagent" && item.subagentId === subagent?.id,
);
assert.equal(
subagentTurnItem?.type === "subagent" ? subagentTurnItem.progress : undefined,
subagent?.progress,
"the turn item mobile reads progress from must match the subagent card",
);
// Its completion drains into continuation run 2, but the subagent and its
// node stay attributed to the run that launched it.
assert.lengthOf(projection.runs, 2);
Expand Down
Loading