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
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,71 @@ describe("ProviderRuntimeIngestion", () => {
).toBe("interrupted");
});

it("settles a foreground subagent when its turn completes and leaves a background one running", async () => {
const harness = await createHarness();
const turnId = asTurnId("turn-foreground-settle");

harness.emit({
type: "turn.started",
eventId: asEventId("evt-turn-started-foreground-settle"),
provider: ProviderDriverKind.make("claudeAgent"),
threadId: asThreadId("thread-1"),
createdAt: "2026-01-01T00:00:00.000Z",
turnId,
});
// A foreground agent blocks its turn until it returns, so once the turn
// has ended on its own the agent's stop event can only be missing. A
// background agent legitimately keeps working after the turn that
// spawned it.
harness.emit({
type: "subagent.metadata.updated",
eventId: asEventId("evt-subagent-foreground-running"),
provider: ProviderDriverKind.make("claudeAgent"),
threadId: asThreadId("thread-1"),
createdAt: "2026-01-01T00:00:01.000Z",
turnId,
payload: { callId: "call-foreground", status: "running", isBackgrounded: false },
});
harness.emit({
type: "subagent.metadata.updated",
eventId: asEventId("evt-subagent-background-running"),
provider: ProviderDriverKind.make("claudeAgent"),
threadId: asThreadId("thread-1"),
createdAt: "2026-01-01T00:00:02.000Z",
turnId,
payload: { callId: "call-background", status: "running", isBackgrounded: true },
});
await waitForThread(
harness.readModel,
(thread) =>
(thread.subagents ?? []).filter((subagent) => subagent.status === "running").length === 2,
);

harness.emit({
type: "turn.completed",
eventId: asEventId("evt-turn-completed-foreground-settle"),
provider: ProviderDriverKind.make("claudeAgent"),
threadId: asThreadId("thread-1"),
createdAt: "2026-01-01T00:00:03.000Z",
turnId,
payload: { state: "completed" },
});

const thread = await waitForThread(harness.readModel, (entry) =>
(entry.subagents ?? []).some(
(subagent) => subagent.spawnCallId === "call-foreground" && subagent.status === "completed",
),
);
const foreground = thread.subagents?.find(
(subagent) => subagent.spawnCallId === "call-foreground",
);
expect(foreground?.status).toBe("completed");
expect(foreground?.updatedAt).toBe("2026-01-01T00:00:03.000Z");
expect(
thread.subagents?.find((subagent) => subagent.spawnCallId === "call-background")?.status,
).toBe("running");
});

it("applies provider session.state.changed transitions directly", async () => {
const harness = await createHarness();
const waitingAt = "2026-01-01T00:00:00.000Z";
Expand Down
34 changes: 26 additions & 8 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2511,6 +2511,7 @@ const make = Effect.gen(function* () {
const settleLiveSubagents = Effect.fn("settleLiveSubagents")(function* (options: {
readonly commandTag: string;
readonly summary: string;
readonly status: "completed" | "interrupted";
readonly belongsToLifecycle: (
subagent: NonNullable<OrchestrationThread["subagents"]>[number],
) => boolean;
Expand All @@ -2537,7 +2538,7 @@ const make = Effect.gen(function* () {
payload: {
...(subagent.agentThreadId ? { agentThreadId: subagent.agentThreadId } : {}),
...(subagent.spawnCallId ? { callId: subagent.spawnCallId } : {}),
status: "interrupted",
status: options.status,
},
turnId: subagent.turnId,
createdAt: event.createdAt,
Expand Down Expand Up @@ -2565,6 +2566,7 @@ const make = Effect.gen(function* () {
yield* settleLiveSubagents({
commandTag: "subagent-orphan",
summary: "Subagent no longer tracked by the provider session",
status: "interrupted",
belongsToLifecycle: () => true,
});
if (event.type === "session.started") {
Expand Down Expand Up @@ -2670,13 +2672,29 @@ const make = Effect.gen(function* () {
(_key, pending) =>
pending.threadId === thread.id && sameId(pending.event.turnId, eventTurnId),
);
}
if (turnWasInterrupted && shouldApplyThreadLifecycle && eventTurnId !== undefined) {
yield* settleLiveSubagents({
commandTag: "subagent-turn-aborted",
summary: "Subagent interrupted with its parent turn",
belongsToLifecycle: (subagent) => sameId(subagent.turnId, eventTurnId),
});
// An interrupted turn takes every agent of the turn down with it. A
// turn that ended on its own can only have outlived a foreground
// agent by losing its stop event, because a foreground spawn blocks
// the turn until it returns. Background agents (spawned in the
// background, or moved there mid-run) legitimately keep working after
// the turn that spawned them, and Codex never says which is which, so
// only agents the provider marked foreground are settled here.
yield* settleLiveSubagents(
turnWasInterrupted
? {
commandTag: "subagent-turn-aborted",
summary: "Subagent interrupted with its parent turn",
status: "interrupted",
belongsToLifecycle: (subagent) => sameId(subagent.turnId, eventTurnId),
}
: {
commandTag: "subagent-turn-settled",
summary: "Subagent settled with its parent turn",
status: completedTurnState === "completed" ? "completed" : "interrupted",
belongsToLifecycle: (subagent) =>
subagent.isBackgrounded === false && sameId(subagent.turnId, eventTurnId),
},
);
}

if (
Expand Down
144 changes: 144 additions & 0 deletions apps/server/src/orchestration/subagentProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,40 @@ function claudeSpawnActivity(input: {
});
}

/** The completion the adapter synthesizes from a `<task-notification>` for an
* agent it holds no spawn for: keyed by the call it knows the agent by, with
* the notification's task id and the agent's final text under `data`. */
function notificationReceipt(input: {
id: string;
toolCallId: string;
taskId: string;
text: string;
createdAt?: string;
}): OrchestrationThreadActivity {
return activity({
id: input.id,
kind: "tool.completed",
...(input.createdAt ? { createdAt: input.createdAt } : {}),
payload: {
itemType: "collab_agent_tool_call",
toolCallId: input.toolCallId,
status: "completed",
title: "Subagent task",
detail: 'Agent "Fix missing-worktree bug trio" finished',
data: {
toolName: "Agent",
input: {},
result: {
type: "tool_result",
tool_use_id: input.toolCallId,
content: [{ type: "text", text: input.text }],
},
taskNotification: { taskId: input.taskId, status: "completed" },
},
},
});
}

describe("projectSubagentActivity", () => {
it("creates a roster row from a Claude Agent tool call", () => {
const roster = projectSubagentActivity(
Expand Down Expand Up @@ -415,6 +449,116 @@ describe("projectSubagentActivity", () => {
expect(settled[0]?.objective).toBe("Review");
});

it("ignores a flag-only metadata patch that names no known agent", () => {
const spawned = projectSubagentActivity(
[],
claudeSpawnActivity({
id: "a1",
kind: "tool.started",
status: "inProgress",
turnId: TURN_ID,
}),
);

// A shell command the harness moved to the background reports the same
// flag under its own tool call. It is not an agent and gets no row.
const afterCommand = projectSubagentActivity(
spawned,
activity({
id: "a2",
kind: "subagent.metadata",
payload: { callId: "toolu_bash_background", isBackgrounded: true },
}),
);
expect(afterCommand).toHaveLength(1);

// A resumed agent restates its depth and background flags under the call
// that resumed it. Still one agent, and nothing to key a second row by.
const afterResumeFlags = projectSubagentActivity(
afterCommand,
activity({
id: "a3",
kind: "subagent.metadata",
turnId: RESUME_TURN_ID,
payload: { callId: "toolu_01SendMessageResume", treeDepth: 1, isBackgrounded: true },
}),
);
expect(afterResumeFlags).toHaveLength(1);

// The same flags addressed to the spawn still land on its row.
const flagged = projectSubagentActivity(
afterResumeFlags,
activity({
id: "a4",
kind: "subagent.metadata",
payload: { callId: SPAWN_TOOL_USE_ID, isBackgrounded: true },
}),
);
expect(flagged).toHaveLength(1);
expect(flagged[0]?.isBackgrounded).toBe(true);
expect(flagged[0]?.status).toBe("running");
});

it("files a resumed agent's replayed report on its row by task id", () => {
const spawned = projectSubagentActivity(
[],
claudeSpawnActivity({
id: "a1",
kind: "tool.started",
status: "inProgress",
turnId: TURN_ID,
}),
);
const linked = projectSubagentActivity(
spawned,
activity({
id: "a2",
kind: "task.started",
turnId: TURN_ID,
payload: {
taskId: "a53e9dad4acb0ffce",
toolUseId: SPAWN_TOOL_USE_ID,
taskType: "local_agent",
subagentType: "claude",
},
}),
);

// After a restart the adapter holds no spawn for the agent and files its
// final report as a completion of the call that resumed it.
const reported = projectSubagentActivity(
linked,
notificationReceipt({
id: "a3",
toolCallId: "toolu_01SendMessageResume",
taskId: "a53e9dad4acb0ffce",
text: "## Report\n\nStep 4a done.",
createdAt: "2026-08-15T00:10:00.000Z",
}),
);
expect(reported).toHaveLength(1);
expect(reported[0]).toMatchObject({
spawnCallId: SPAWN_TOOL_USE_ID,
status: "completed",
resultBody: "## Report\n\nStep 4a done.",
objective: "Fix missing-worktree bug trio",
});

// A report naming no known agent stands for itself: the output is kept.
const orphan = projectSubagentActivity(
reported,
notificationReceipt({
id: "a4",
toolCallId: "toolu_01Unknown",
taskId: "unknown-task",
text: "Lost agent output.",
createdAt: "2026-08-15T00:20:00.000Z",
}),
);
expect(orphan).toHaveLength(2);
expect(orphan[1]?.resultBody).toBe("Lost agent output.");
});

it("still folds Codex-shaped collab items", () => {
const roster = projectSubagentActivity(
[],
Expand Down
47 changes: 36 additions & 11 deletions apps/server/src/orchestration/subagentProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
} from "@threadlines/contracts";
import {
claudeSubagentActivityItem,
claudeSubagentNotificationTaskId,
isClaudeAgentTaskPayload,
isClaudeSubagentToolName,
isSpawnAgentTool,
} from "@threadlines/shared/claudeSubagentActivity";
Expand Down Expand Up @@ -107,6 +109,8 @@ interface SubagentPatch {
readonly reasoningEffortProvenance?: OrchestrationSubagentSettingProvenance | null;
readonly resultBody?: string | null;
readonly resultCreatedAt?: string | null;
/** Task id of the notification a replayed agent result was built from. */
readonly notificationTaskId?: string | null;
}

function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | null {
Expand Down Expand Up @@ -197,6 +201,7 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] {
const agentPath = text(item.agentPath);
const requestedModel = text(item.model);
const reasoningEffort = text(item.reasoningEffort);
const notificationTaskId = claudeSubagentNotificationTaskId(data);
return ids.map((id) => {
const state = record(states?.[id]);
return {
Expand Down Expand Up @@ -225,6 +230,7 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] {
reasoningEffortProvenance: reasoningEffort ? "explicit" : null,
resultBody: text(state?.message),
resultCreatedAt: text(state?.message) ? activity.createdAt : null,
notificationTaskId,
};
});
}
Expand Down Expand Up @@ -310,16 +316,6 @@ function settleTaskCompletion(
return next;
}

const AGENT_TASK_TYPES = new Set(["local_agent", "remote_agent"]);

/** Background command tasks share the task activity kinds with agent tasks and
* must never move an agent's row. */
function isAgentTaskActivity(payload: UnknownRecord | null): boolean {
return (
text(payload?.subagentType) !== null || AGENT_TASK_TYPES.has(text(payload?.taskType) ?? "")
);
}

function isSettledStatus(status: OrchestrationSubagentStatus): boolean {
return status === "completed" || status === "failed" || status === "interrupted";
}
Expand All @@ -341,7 +337,7 @@ function reopenResumedAgentRun(
activity: OrchestrationThreadActivity,
): ReadonlyArray<OrchestrationSubagent> {
const payload = record(activity.payload);
if (!isAgentTaskActivity(payload)) return current;
if (!isClaudeAgentTaskPayload(payload)) return current;
const taskId = text(payload?.taskId);
const toolUseId = text(payload?.toolUseId);
const index = current.findIndex(
Expand Down Expand Up @@ -423,6 +419,35 @@ export function projectSubagentActivity(
matches.push(index);
}
}
// A replayed final report is filed under the call the adapter knows the
// agent by. After a provider restart that is the call that resumed the
// agent, which owns no row; the task id it carries still names the agent.
// Only the lifecycle lands there: the synthesized item knows nothing else
// about the agent. A report naming no known agent stands for itself below,
// so the agent's only output is kept.
if (matches.length === 0 && patch.notificationTaskId) {
const owner = next.findIndex((entry) => entry.transcriptAgentId === patch.notificationTaskId);
const row = owner >= 0 ? next[owner] : undefined;
if (row) {
next[owner] = mergeSubagent(
row,
{
id: row.id,
...(patch.status === undefined ? {} : { status: patch.status }),
resultBody: patch.resultBody ?? null,
resultCreatedAt: patch.resultCreatedAt ?? null,
},
activity,
);
continue;
}
}
// A patch that states no lifecycle status (a background flag, a nesting
// depth) describes an agent some other activity introduced. When none did
// — a shell command the harness moved to the background, a resume call
// for a spawn this process never saw — there is no agent to describe, and
// a `pending:` row keyed by that call would never receive a stop.
if (matches.length === 0 && patch.status === undefined) continue;
const [primary, ...absorbed] = matches;
let base = primary !== undefined ? next[primary] : undefined;
for (const index of absorbed) {
Expand Down
Loading
Loading