fix(server): stop Claude replay from accumulating running subagents (#2876)

* fix(server): finish subagents the parent transcript can never end

A skill-spawned subagent — a /code-review run, for example — has no Task
tool_use for the parent to record, so its sidecar carries no toolUseId
and its id appears nowhere in the parent transcript. Replay resolved no
link, emitted no status observation, and the store defaulted the
descriptor to running. The subagents track then reported a running child
on every reopen, days after it had finished, and the archive dialog
offered to stop it.

Paseo only records an outcome for a child whose Task call it parsed out
of the parent, so a child with no Task call is one no reader can finish.
Resolve identity and status together and mark those completed. A child
that does have a Task call but no result yet is the opposite case — the
parent was interrupted mid-Task — and keeps running.

Keying on "no Task call for this id" rather than "no toolUseId" also
covers legacy grandchildren. Across 554 recorded subagent transcripts,
5 stuck rows had no toolUseId and 8 more named a Task call made inside a
sibling's session; none of the 13 has a recoverable tool_result in the
parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): admit only declared Claude subagents on replay

* test(server): cover Claude workflow subagent accumulation

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Christoph Leiter
2026-08-05 23:23:00 +02:00
committed by GitHub
co-authored by Claude Opus 5 Mohamed Boudra
parent fb5cfb9fb9
commit 7f7e8923eb
3 changed files with 281 additions and 59 deletions
@@ -57,14 +57,18 @@ function taskToolResult(options: { isError?: boolean; mentionAgentId?: boolean }
});
}
function sidechainEntry(): string {
function sidechainEntry(options: { agentId?: string; stopReason?: string | null } = {}): string {
return JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
agentId: options.agentId ?? AGENT_ID,
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:50.000Z",
message: { role: "assistant", content: [{ type: "text", text: "summary of the docs" }] },
message: {
role: "assistant",
content: [{ type: "text", text: "summary of the docs" }],
stop_reason: options.stopReason ?? null,
},
});
}
@@ -77,24 +81,40 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
let cwd: string;
let configDir: string;
function writeParentSession(parentLines: string[]): string {
const historyDir = claudeProjectDirSync(cwd, { configDir });
mkdirSync(historyDir, { recursive: true });
writeFileSync(path.join(historyDir, "replay-session.jsonl"), parentLines.join("\n"));
return path.join(historyDir, "replay-session", "subagents");
}
function writeSubagent(options: {
subagentDir: string;
agentId?: string;
meta?: string | null;
sidechainLines?: string[];
}): void {
const agentId = options.agentId ?? AGENT_ID;
mkdirSync(options.subagentDir, { recursive: true });
writeFileSync(
path.join(options.subagentDir, `agent-${agentId}.jsonl`),
(options.sidechainLines ?? [sidechainEntry({ agentId })]).join("\n"),
);
if (options.meta !== null && options.meta !== undefined) {
writeFileSync(path.join(options.subagentDir, `agent-${agentId}.meta.json`), options.meta);
}
}
function writeSession(options: {
parentLines: string[];
meta?: string | null;
sidechainLines?: string[];
}): void {
const historyDir = claudeProjectDirSync(cwd, { configDir });
mkdirSync(historyDir, { recursive: true });
writeFileSync(path.join(historyDir, "replay-session.jsonl"), options.parentLines.join("\n"));
const subagentDir = path.join(historyDir, "replay-session", "subagents");
mkdirSync(subagentDir, { recursive: true });
writeFileSync(
path.join(subagentDir, `agent-${AGENT_ID}.jsonl`),
(options.sidechainLines ?? [sidechainEntry()]).join("\n"),
);
if (options.meta !== null && options.meta !== undefined) {
writeFileSync(path.join(subagentDir, `agent-${AGENT_ID}.meta.json`), options.meta);
}
writeSubagent({
subagentDir: writeParentSession(options.parentLines),
meta: options.meta,
sidechainLines: options.sidechainLines,
});
}
async function replayDescriptors(): Promise<
@@ -198,6 +218,69 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(statuses).toContain("running");
});
test("finishes a Task subagent from its own end_turn when the parent outcome is missing", async () => {
writeSession({
parentLines: [taskToolUse()],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: "completed",
});
});
test("does not replay a skill-spawned subagent the parent never named", async () => {
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
// and every reopen resurrected it.
writeSession({
parentLines: [parentEntry([{ type: "text", text: "running /code-review" }])],
meta: JSON.stringify({ agentType: "general-purpose" }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors())).toEqual([]);
});
test("does not accumulate internal workflow agents as replay-only running rows", async () => {
const subagentRoot = writeParentSession([
parentEntry([
{
type: "tool_use",
id: "toolu_workflow",
name: "Workflow",
input: { script: "await Promise.all([agent('one'), agent('two'), agent('three')])" },
},
]),
]);
const workflowDir = path.join(subagentRoot, "workflows", "wf_c240e728-6ea");
for (const agentId of ["a17a341a0902f5799", "ae2d01ba2dfa46032", "abc9f20546c326c53"]) {
writeSubagent({
subagentDir: workflowDir,
agentId,
meta: JSON.stringify({ agentType: "workflow-subagent", spawnDepth: 1 }),
sidechainLines: [sidechainEntry({ agentId, stopReason: "end_turn" })],
});
}
expect(upserts(await replayDescriptors())).toEqual([]);
});
test("does not replay a subagent whose toolUseId names no Task call in this transcript", async () => {
// A grandchild recorded before spawnDepth existed: the Task call it names was made inside a
// sibling's session, so no tool_result for it can ever reach this parent.
writeSession({
parentLines: [parentEntry([{ type: "text", text: "no task calls here" }])],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID, agentType: "Explore" }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors())).toEqual([]);
});
test.each([
["truncated json", '{"toolUseId":"toolu_01DgLoPMW9"'],
["not json", "toolUseId: toolu"],
@@ -99,7 +99,7 @@ describe("observeReplaySubagents", () => {
// Claude Code writes every descendant into the same subagents/ directory. A grandchild's
// toolUseId names a Task call made inside its parent's session, so nothing here can resolve
// it: it would replay as an extra row the live stream never showed, with no Task card and no
// outcome, running forever. One recorded session showed 10 subagents live and 22 on reopen.
// outcome. One recorded session showed 10 subagents live and 22 on reopen.
const observations = observeReplaySubagents({
subagents: [
{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] },
@@ -126,8 +126,7 @@ describe("observeReplaySubagents", () => {
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
it("recovers identity from meta when the parent's Task call is missing", () => {
// The case the legacy scrape cannot handle: no tool_result, so no link and no identity.
it("drops a meta-linked subagent when the parent never declared its Task", () => {
const observations = observeReplaySubagents({
subagents: [
{
@@ -140,12 +139,7 @@ describe("observeReplaySubagents", () => {
convertEntry: () => [],
});
expect(observations[0]).toMatchObject({
id: TOOL_USE_ID,
toolCallId: TOOL_USE_ID,
title: "Explore",
description: "Find the code",
});
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", () => {
@@ -165,14 +159,127 @@ describe("observeReplaySubagents", () => {
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
});
it("keeps an unlinkable subagent visible under its own id", () => {
const [declared] = observeReplaySubagents({
it("drops an unlinkable subagent", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
});
expect(declared).toMatchObject({ id: AGENT_ID });
expect(declared).not.toHaveProperty("toolCallId");
expect(observations).toEqual([]);
});
it("drops a terminal subagent the parent has no Task call for", () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}),
);
expect(descriptor).toBeNull();
});
it("drops a terminal skill-spawned subagent that has no link at all", () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}),
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}),
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
});
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}),
);
expect(descriptor).toMatchObject({ status: "completed" });
});
it("leaves a subagent with no recorded outcome running", () => {
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -152,51 +153,81 @@ export interface ClaudeReplayParentFacts {
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
}
interface ResolvedLink {
interface ParentLink {
id: string;
toolCallId: string | null;
failed: boolean | null;
toolCallId: string;
/** Terminal status recorded by the parent, or null when its Task has no outcome yet. */
status: ProviderSubagentStatus | null;
}
/**
* Resolve the canonical subagent id.
* Resolve a child only when the parent transcript declares the corresponding Task.
*
* `meta.toolUseId` is authoritative: it is the same id the live stream keys on. The legacy path
* scrapes `agentId:` out of stringified tool-result content, which both misses the link when the
* parent's tool_result is absent and matches unrelated text — on a real five-subagent session it
* produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback for
* sessions recorded before the meta file existed.
* `meta.toolUseId` is authoritative for identity — it is the same id the live stream keys on. The
* legacy path scrapes `agentId:` out of stringified tool-result content, which both misses the link
* when the parent's tool_result is absent and matches unrelated text — on a real five-subagent
* session it produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback
* for sessions recorded before the meta file existed.
*
* ```
* meta.toolUseId matches a parent Task?
* ├─ yes → use it and the parent's outcome, when present
* └─ no → a legacy scraped agentId link matches a parent Task?
* ├─ yes → use it and its recorded outcome
* └─ no → drop it; live never declared this child either
* ```
*
* Claude writes grandchildren and skill-owned sidechains beside direct children. Admitting those
* without a parent Task creates replay-only rows with no identity or lifecycle source. Filtering
* here keeps live and replay on the same declaration boundary.
*/
function resolveLink(
function resolveParentLink(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
): ResolvedLink {
): ParentLink | null {
const metaToolUseId = subagent.meta?.toolUseId?.trim();
if (metaToolUseId) {
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
let status: ProviderSubagentStatus | null = null;
if (outcome) status = outcome.failed ? "failed" : "completed";
return {
id: metaToolUseId,
toolCallId: metaToolUseId,
failed: outcome ? outcome.failed : null,
status,
};
}
const scraped = parent.linksByAgentId.get(subagent.agentId);
if (scraped) {
return { id: scraped.toolCallId, toolCallId: scraped.toolCallId, failed: scraped.failed };
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
return {
id: scraped.toolCallId,
toolCallId: scraped.toolCallId,
status: scraped.failed ? "failed" : "completed",
};
}
// No link at all: keep the subagent visible under its own agent id rather than dropping it.
return { id: subagent.agentId, toolCallId: null, failed: null };
return null;
}
/** A final `end_turn` is the child's own completion signal when the parent result is absent. */
function readChildTerminalStatus(
entries: readonly ClaudeReplayEntry[],
): ProviderSubagentStatus | null {
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
if (entry?.type !== "assistant") continue;
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
return null;
}
/**
* The parent's tool input is the same source the live path reads, so it wins; meta.json fills the
* gap when the parent's Task call is missing from the transcript.
* The parent's tool input is the same identity source the live path reads; meta.json contributes
* provider details only after that declaration has admitted the child.
*/
function declareSubagent(
subagent: ClaudeReplaySubagentInput,
link: ResolvedLink,
link: ParentLink,
toolCall: { title?: string; description?: string } | undefined,
): SubagentObservation {
const title = toolCall?.title ?? subagent.meta?.agentType;
@@ -214,7 +245,7 @@ function declareSubagent(
function observeSubtitle(
subagent: ClaudeReplaySubagentInput,
link: ResolvedLink,
link: ParentLink,
title: string | undefined,
): SubagentObservation | null {
const runtime = readRuntime(subagent.entries);
@@ -244,8 +275,9 @@ function observeSubagent(
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
): SubagentObservation[] {
const link = resolveLink(subagent, parent);
const toolCall = link.toolCallId ? parent.toolCalls.get(link.toolCallId) : undefined;
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)];
const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType);
@@ -263,14 +295,15 @@ function observeSubagent(
}
}
// A transcript with no recorded outcome is a subagent that never finished — leave it running
// rather than inventing a completion.
if (link.failed !== null) {
// Prefer the parent's result because it carries failure. If that result is missing, the child's
// final end_turn still proves completion. Any other shape remains running.
const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries);
if (terminalStatus) {
const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
observations.push({
kind: "status",
id: link.id,
status: link.failed ? "failed" : "completed",
status: terminalStatus,
...(lastTimestamp ? { timestamp: lastTimestamp } : {}),
});
}
@@ -286,9 +319,8 @@ function observeSubagent(
* another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session,
* so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1
* id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them
* anyway adds rows the live stream never showed, each with no Task card and no recoverable
* outcome, which leaves them running forever. One session recorded 10 subagents live and would
* replay 22.
* anyway adds rows the live stream never showed, each with no Task card and no recoverable outcome.
* One session recorded 10 subagents live and would replay 22.
*
* Absent depth means a session recorded before the field existed: keep the subagent rather than
* lose it, matching how every other missing field here is treated.