From 8353a93f7196f48574f125ed2079ac4877355eb9 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:58:09 +0200 Subject: [PATCH] Keep an interrupted background Claude subagent canceled after a daemon restart Replay read a background Agent call's immediate async-launch result as the child's outcome, so every background subagent restored as completed. Skip that result, and read a child transcript that ends on the interrupt placeholder as canceled, which is what the live path reported. --- .../claude/agent.sub-agent-replay.test.ts | 123 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 20 +-- .../providers/claude/interrupt-placeholder.ts | 6 + .../claude/subagents/replay-source.test.ts | 8 +- .../claude/subagents/replay-source.ts | 53 ++++---- 5 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 48ddab5973..9eddd091ef 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -419,6 +419,129 @@ describe("ClaudeAgentSession persisted subagent replay", () => { }); }); + describe("a background subagent", () => { + // Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result, + // and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn. + function backgroundLaunch(): string[] { + return [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "probe sleeper", + run_in_background: true, + }, + }, + ]), + JSON.stringify({ + type: "user", + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:48.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: TOOL_USE_ID, + content: [ + { + type: "text", + text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`, + }, + ], + }, + ], + }, + toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID }, + }), + ]; + } + + function readStatus(event: { status?: string }): string | undefined { + return event.status; + } + + function interruptedChild(): string[] { + return [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:27:50.000Z", + message: { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }], + stop_reason: "tool_use", + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:28:05.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_child_bash", + content: "The user doesn't want to proceed with this tool use.", + is_error: true, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:28:05.001Z", + message: { + role: "user", + content: [{ type: "text", text: "[Request interrupted by user for tool use]" }], + }, + }), + ]; + } + + test.each([ + ["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })], + ["the legacy scrape", null], + ])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => { + writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: "canceled", + }); + }); + + test("stays running while the child has not finished", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + + const statuses = upserts(await replayDescriptors()).map(readStatus); + expect(statuses).not.toContain("completed"); + expect(statuses).toContain("running"); + }); + + test("completes from the child's own end_turn", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ 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, diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index f9f79862ad..cb65529a4a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -30,6 +30,7 @@ import { mapTaskNotificationUserContentToToolCall, readTaskNotificationToolUseIdFromHistoryRecord, } from "./task-notification-tool-call.js"; +import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js"; import { findClaudeModel, getClaudeModelsWithSettings, @@ -382,7 +383,6 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([ "usage", ]); const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]"; -const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/; const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested."; const STEER_SUPERSEDED_PERMISSION_MESSAGE = "The user answered with a message instead of approving. Their message follows."; @@ -732,11 +732,6 @@ function normalizeClaudeTranscriptText(value: unknown): string | null { return normalized.length > 0 ? normalized : null; } -function isClaudeInterruptPlaceholderText(value: unknown): boolean { - const normalized = normalizeClaudeTranscriptText(value); - return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized); -} - function isClaudeNoResponsePlaceholderText(value: unknown): boolean { return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER; } @@ -5901,8 +5896,10 @@ function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): Claud // Read outcomes straight off the tool_result blocks rather than reusing the agentId scrape, // so a subagent linked through its meta sidecar still gets a status when the scrape missed it. + // A background launch's result only says the child started, so it is not an outcome. const outcomesByToolCallId = new Map(); for (const entry of parentEntries) { + if (isClaudeAsyncLaunchResult(entry)) continue; const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { @@ -5920,6 +5917,11 @@ function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): Claud }; } +/** Claude Code answers a background Agent call at once, before the child has done anything. */ +function isClaudeAsyncLaunchResult(entry: ClaudeHistoryEntry): boolean { + return toObjectRecord(entry.toolUseResult)?.status === "async_launched"; +} + interface ClaudeSidechainHistory { contents: string[]; workflowContents: string[]; @@ -6044,8 +6046,8 @@ function readClaudeHistoricalSubagentToolCalls( function readClaudeHistoricalSubagentToolResults( entries: ClaudeHistoryEntry[], -): Map { - const results = new Map(); +): Map { + const results = new Map(); for (const entry of entries) { const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; @@ -6054,7 +6056,7 @@ function readClaudeHistoricalSubagentToolResults( if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content)); if (!match?.[1]) continue; - results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true }); + results.set(match[1], { toolCallId: block.tool_use_id }); } } return results; diff --git a/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts b/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts new file mode 100644 index 0000000000..f74c899234 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts @@ -0,0 +1,6 @@ +const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/; + +/** The text Claude Code records in place of a turn that an interrupt stopped. */ +export function isClaudeInterruptPlaceholderText(value: unknown): boolean { + return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim()); +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index e28d15b8c1..d03a454ecb 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -159,8 +159,8 @@ describe("observeReplaySubagents", () => { it("falls back to the scraped link when there is no meta file", () => { const parent: ClaudeReplayParentFacts = { toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), - linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), - outcomesByToolCallId: new Map(), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed: false }]]), }; const [declared, ...rest] = observeReplaySubagents({ @@ -240,8 +240,8 @@ describe("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(), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed: true }]]), }, convertEntry: () => [], }).observations, diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 372cbc01b2..dbe9720769 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -3,6 +3,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 { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js"; import { resolveObservedClaudeModelId } from "../models.js"; import type { SubagentObservation } from "./observation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; @@ -150,8 +151,11 @@ export interface ClaudeReplayParentFacts { /** Task `tool_use` id -> identity declared in the parent's tool input. */ toolCalls: ReadonlyMap; /** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */ - linksByAgentId: ReadonlyMap; - /** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */ + linksByAgentId: ReadonlyMap; + /** + * Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has + * none: its immediate result only says the child started. + */ outcomesByToolCallId: ReadonlyMap; } @@ -188,41 +192,44 @@ function resolveParentLink( parent: ClaudeReplayParentFacts, ): ParentLink | null { const metaToolUseId = subagent.meta?.toolUseId?.trim(); - 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, - status, - }; - } + const toolCallId = + metaToolUseId && parent.toolCalls.has(metaToolUseId) + ? metaToolUseId + : parent.linksByAgentId.get(subagent.agentId)?.toolCallId; + if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null; - const scraped = parent.linksByAgentId.get(subagent.agentId); - if (scraped && parent.toolCalls.has(scraped.toolCallId)) { - return { - id: scraped.toolCallId, - toolCallId: scraped.toolCallId, - status: scraped.failed ? "failed" : "completed", - }; - } - - return null; + const outcome = parent.outcomesByToolCallId.get(toolCallId); + let status: ProviderSubagentStatus | null = null; + if (outcome) status = outcome.failed ? "failed" : "completed"; + return { id: toolCallId, toolCallId, status }; } -/** A final `end_turn` is the child's own completion signal when the parent result is absent. */ +/** + * The child's own transcript settles it when the parent result is absent: a final `end_turn` is a + * completion, and a final interrupt placeholder is the cancellation live reported. + */ function readChildTerminalStatus( entries: readonly ClaudeReplayEntry[], ): ProviderSubagentStatus | null { for (let index = entries.length - 1; index >= 0; index--) { const entry = entries[index]; + if (entry?.type === "user" && isInterruptPlaceholderEntry(entry)) return "canceled"; if (entry?.type !== "assistant") continue; return entry.message?.stop_reason === "end_turn" ? "completed" : null; } return null; } +function isInterruptPlaceholderEntry(entry: ClaudeReplayEntry): boolean { + const content = entry.message?.content; + if (typeof content === "string") return isClaudeInterruptPlaceholderText(content); + if (!Array.isArray(content)) return false; + return content.some( + (block: { type?: unknown; text?: unknown } | null) => + block?.type === "text" && isClaudeInterruptPlaceholderText(block.text), + ); +} + /** * 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.