Skip to content

[Bug]: a Task sub-agent's end_turn is treated as the main turn's completion — a crashed run is misclassified as succeeded #5487

Description

@tomsen02

Summary

The Claude stream-json parser treats a sub-agent (Task) assistant frame exactly like a main-turn frame. apps/daemon/src/runtimes/claude-stream.ts:406-456 reads message.stop_reason off ANY type:'assistant' wrapper and, when it is not 'tool_use', emits turn_end — it never inspects the top-level parent_tool_use_id (claude-code sets this on every message a sub-agent emits; main-turn messages carry parent_tool_use_id: null). Claude is spawned with --verbose (apps/daemon/src/runtimes/defs/claude.ts:59), so sub-agent frames appear inline in the stream.

Consequence: when a Task sub-agent finishes its own internal turn with stop_reason: 'end_turn', applyClaudeStreamJsonRunBookkeeping (apps/daemon/src/runtimes/chat-run-lifecycle.ts:98-118) sets run.turnCompletedCleanly = true and closes stdin while the MAIN turn is still running. If the main agent then terminates non-zero WITHOUT a result frame (hard crash / OOM kill / connection-drop abort mid-synthesis), the close handler finds turnCompletedCleanly === true and classifyChatRunCloseStatus (chat-run-lifecycle.ts:70) translates the non-zero exit into succeeded — a silent false success that hides the failure, skips the same-run retry, and surfaces the sub-agent's internal scratch as the "answer".

User-visible symptom

A user asks something that makes Claude spawn a Task sub-agent. The sub-agent finishes; the main agent then crashes while synthesizing the final answer (dropped/killed/OOM — non-zero exit, no result frame). This should be reported as a retryable failure; instead the run is marked succeeded and the sub-agent's internal draft is delivered as the answer.

Root cause (file:line)

  • apps/daemon/src/runtimes/claude-stream.ts:450if (stopReason) emits turn_end without checking obj.parent_tool_use_id, and the emitted event drops that field entirely.
  • apps/daemon/src/runtimes/chat-run-lifecycle.ts:98-118 — a non-tool_use turn_end sets turnCompletedCleanly = true + closes stdin.
  • apps/daemon/src/runtimes/chat-run-lifecycle.ts:70if (turnCompletedCleanly) return 'succeeded' translates the non-zero exit into success.
  • Corroboration: parent_tool_use_id appears nowhere in the daemon source.

Reproduction (live — real daemon + production HTTP API)

Two fake claude bins whose ONLY difference is the presence of a sub-agent end_turn frame; both exit 1 with no result frame. Driven entirely over POST /api/projects, POST /api/runs, GET /api/runs/:id; CLAUDE_BIN overridden via app-config; frames written with fs.writeSync; no source backdoor.

  • control (no sub-agent frame): turnCompletedCleanly stays false → failed ✓ (this control assertion passes first)
  • sidechain (sub-agent end_turn): flag flips true → succeeded

Red on clean origin/main (expected 'succeeded' to be 'failed'); both source files are byte-identical to origin/main. The prototype fix (claude-stream.ts:450 + && obj.parent_tool_use_id == null) turns it green, with no regression in the neighboring parser suites (structured-streams, claude-stream-thinking, retry-stale-turn-completed-flag).

Red spec (drop into apps/daemon/tests/)
import type { Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';

type StartedServer = { url: string; server: Server; shutdown?: () => Promise<void> | void };
type RunStatus = { id: string; status: string; exitCode: number | null };

describe('claude sub-agent turn_end false success', () => {
  const savedEnv: Record<string, string | undefined> = {};
  for (const k of ['POSTHOG_KEY','POSTHOG_HOST','LANGFUSE_PUBLIC_KEY','LANGFUSE_SECRET_KEY','LANGFUSE_BASE_URL','OPEN_DESIGN_TELEMETRY_RELAY_URL']) savedEnv[k] = process.env[k];
  let started: StartedServer | null = null;
  let binDir: string | null = null;

  afterEach(async () => {
    await Promise.resolve(started?.shutdown?.());
    if (started?.server) await new Promise<void>((r) => started?.server.close(() => r()));
    started = null;
    if (binDir) await rm(binDir, { recursive: true, force: true });
    binDir = null;
    for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
  });

  it('a sub-agent end_turn must not classify a crashed main turn as succeeded', async () => {
    binDir = await mkdtemp(path.join(os.tmpdir(), 'od-sidechain-claude-bin-'));
    const controlBin = await writeCrashClaude(binDir, 'claude-control', { withSubagent: false });
    const sidechainBin = await writeCrashClaude(binDir, 'claude-sidechain', { withSubagent: true });
    for (const k of Object.keys(savedEnv)) delete process.env[k];
    started = await startServer({ port: 0, returnServer: true }) as StartedServer;

    await putConfig(started.url, { agentId: 'claude', agentCliEnv: { claude: { CLAUDE_BIN: controlBin } }, telemetry: { metrics: true, content: false, artifactManifest: false }, privacyDecisionAt: Date.now() });
    const controlRun = await createAndWaitForRun(started.url);
    expect(controlRun.exitCode).toBe(1);
    expect(controlRun.status).toBe('failed'); // baseline: crash without sub-agent frame is a failure

    await putConfig(started.url, { agentId: 'claude', agentCliEnv: { claude: { CLAUDE_BIN: sidechainBin } }, telemetry: { metrics: true, content: false, artifactManifest: false }, privacyDecisionAt: Date.now() });
    const sidechainRun = await createAndWaitForRun(started.url);
    expect(sidechainRun.exitCode).toBe(1);
    // INVARIANT: a sub-agent (parent_tool_use_id != null) turn boundary must not vouch for the main turn.
    expect(sidechainRun.status).toBe('failed');
  });
});

async function writeCrashClaude(dir: string, name: string, opts: { withSubagent: boolean }): Promise<string> {
  const bin = path.join(dir, name);
  const subagentFrame = opts.withSubagent
    ? `w(JSON.stringify({ type: 'assistant', parent_tool_use_id: 'tu_task', message: { id: 'msg-sub', content: [{ type: 'text', text: 'sub-agent internal scratch' }], stop_reason: 'end_turn' } }) + '\\n');`
    : '';
  await writeFile(bin, `#!/usr/bin/env node
const fs = require('node:fs');
function w(s) { fs.writeSync(1, s); }
if (process.argv.includes('--version')) { w('claude-code 1.0.0-sidechain-test\\n'); process.exit(0); }
if (process.argv.includes('--help')) { w('Usage: claude -p [--include-partial-messages] [--add-dir DIR]\\n'); process.exit(0); }
w(JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-sidechain-test', session_id: 's-sidechain' }) + '\\n');
w(JSON.stringify({ type: 'assistant', parent_tool_use_id: null, message: { id: 'msg-main', content: [{ type: 'tool_use', id: 'tu_task', name: 'Task', input: { prompt: 'do work' } }], stop_reason: 'tool_use' } }) + '\\n');
${subagentFrame}
setTimeout(() => process.exit(1), 30); // main agent dies mid-synthesis: non-zero, no result frame
`, 'utf8');
  await chmod(bin, 0o755);
  return bin;
}

async function putConfig(url: string, patch: Record<string, unknown>): Promise<void> {
  const r = await fetch(`${url}/api/app-config`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) });
  expect(r.status).toBe(200);
}

async function createAndWaitForRun(url: string): Promise<RunStatus> {
  const projectId = `sidechain_claude_${randomUUID()}`;
  const p = await fetch(`${url}/api/projects`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: projectId, name: 'Sidechain repro', metadata: { kind: 'prototype' }, skipDiscoveryBrief: true }) });
  expect(p.status).toBe(200);
  const { conversationId } = await p.json() as { conversationId: string };
  const r = await fetch(`${url}/api/runs`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-od-analytics-device-id': 'd', 'x-od-analytics-session-id': 's', 'x-od-analytics-client-type': 'web' }, body: JSON.stringify({ projectId, conversationId, assistantMessageId: `a_${randomUUID()}`, clientRequestId: `c_${randomUUID()}`, agentId: 'claude', message: 'repro', currentPrompt: 'repro' }) });
  expect(r.status).toBe(202);
  const { runId } = await r.json() as { runId: string };
  const start = Date.now();
  while (Date.now() - start < 10_000) {
    const s = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`);
    const run = await s.json() as RunStatus;
    if (['failed', 'succeeded', 'canceled'].includes(run.status)) return run;
    await new Promise((res) => setTimeout(res, 100));
  }
  throw new Error(`run ${runId} did not finish`);
}

Proposed direction + open question

Invariant: a sub-agent's turn boundary must not vouch for the whole run. Two shapes:

  • (A, recommended) suppress at the source: claude-stream.ts:450 + && obj.parent_tool_use_id == null so a sub-agent frame never emits turn_end. One place aligns all three chained effects — setting the clean flag, closing stdin, AND resetting the per-turn artifact-echo dedup state (recentWriteContents / wroteHtmlFileThisTurn, which sits in the same block and equally should not fire for a sub-agent).
  • (B) thread the id through: carry parent_tool_use_id into the turn_end event and let the bookkeeping layer decide. Downside: the artifact-dedup reset lives in the parser right next to the turn_end emit, so (B) needs a second guard there or a sub-agent frame still wrongly resets main-turn state.

I'd like a maintainer steer on A vs B (leaning A) before opening a PR.

Dedup

No collision. #4197 only handled the main turn's tool_use not closing stdin; #5340/#5341 (same-run retry inheriting attempt 1's clean flag) is a cross-attempt issue fixed via tearDownAttemptForRetry and does not go through this path; #3372 is a SessionEnd-hook non-zero exit, a different family. All adjacent, none duplicate.

Severity + honest caveats

  • High: silent false success + swallowed failure + skipped retry, delivering a truncated/wrong answer. The same invariant family has been specifically guarded twice already (Claude stream-json usage with tool_use stop reason is treated as terminal #4197, [Bug]: same-run retry inherits attempt 1's clean-turn flag, classifying a crashed retry as succeeded #5340); the parser just missed the sidechain dimension — not defensible as intended behavior.
  • Trigger window: claude (the default) + a Task sub-agent (common) + main process terminating non-zero with no result frame. A graceful exit 0, and a crash carrying an is_error result, both still classify correctly.
  • Two honest caveats: (1) I have live-verified the "false success" half; the "stdin closed early truncates the main turn" half is reasoning-only, not reproduced. (2) The chain depends on real claude-code emitting sub-agent messages inline with parent_tool_use_id and ending with end_turn — this matches the documented --verbose stream-json format, but I reproduced it with a fake CLI emitting such a frame (proving the daemon parser mishandles it), not end-to-end against a real claude binary.
  • Blast radius: single run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions