Headless `claude -p` exits with its own background subagents still running; every in-flight Task is reported `stopped` and the run still ends `result.subtype=success`, exit code 0
Environment
- Claude Code CLI 2.1.270 (also reproduced on 2.1.267–2.1.269), Linux x64,
claude -p --output-format stream-json --verbose, launched unattended from a cron-driven shell script (no TTY). - Model: a local OpenAI-compatible vLLM server reached through the Anthropic-compatible base URL; served model name
qwen-local=Qwen/Qwen3.8-27B-FP8, vLLM0.27.2rc1.dev110+gacb0f1dcd(imagevllm/vllm-openai:nightly-acb0f1dcdb668d90bbbf50e57552d2f6f0987c87),--max-num-seqs 8. - Prompt shape: an orchestrator system prompt (
--append-system-promptfrom a custom agent file) that dispatches 5–11 custom subagents (subagent_type= per-ticker analysts) in one turn via the Agent/Task tool, then waits for their returns. Subagent runs take 12–100 minutes each because they queue on the local model server.
Failure case (model omits run_in_background)
When the local model's Agent/Task tool_use input carries only description, prompt, subagent_type (no run_in_background key), the CLI backgrounds the subagent: system/task_started has is_backgrounded: true and the Task tool_result is the placeholder "Async agent launched successfully". The main turn then ends ("awaiting returns"). If no completion lands within roughly 600 s of the previous turn, the -p process exits: every in-flight subagent gets system/task_notification status: "stopped", the parent never sees a result, the final result record is subtype: "success", is_error: false, and the process exit code is 0.
Measured on 2026-09-14 (UTC), one jsonl per session, counts from task_started / task_notification records:
| session | tasks started | backgrounded | completed | stopped | result | wall |
|---|---:|---:|---:|---:|---|---:|
| b6b7af2c | 5 | 5 | 3 | 2 | success, is_error=false, num_turns=2 | 1.9 min |
| 60294b47 | 11 | 11 | 0 | 11 | success, is_error=false, num_turns=20 | 17.4 min |
| 42c89edc | 11 | 11 | 0 | 11 | success, is_error=false, num_turns=19 | 17.6 min |
| d1f7b7dc | 11 | 11 | 0 | 11 | success, is_error=false, num_turns=20 | 21.7 min |
The same pattern appears in our logs on 2026-09-01, 09-02, 09-04, 09-10 and 09-11. In the three 11/11 sessions the last completion-to-exit gap was 602–603 s, and an offline control (two subagents that each backgrounded a long sleep, no other activity) exited 651 s after the previous turn with the subagents still alive — so the exit looks like an idle window of ~600 s with no completion arriving, not an immediate abandonment.
Control case (explicit run_in_background: false)
Sessions where the model passed run_in_background: false ran every subagent in the foreground (is_backgrounded: false), returned every result in the same turn, and lost nothing:
| session | tasks started | backgrounded | completed | failed | stopped | wall |
|---|---:|---:|---:|---:|---:|---:|
| 61f8e81c | 18 | 0 | 15 | 3 (stall watchdog, "Agent stalled: no progress for 600s") | 0 | 200.9 min |
| 9ba482a8 | 1 | 0 | 1 | 0 | 0 | 26.9 min |
Workaround that works
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 in the launcher environment. Under it the Agent/Task tool schema no longer offers run_in_background, every subagent runs in the foreground, and an offline A/B (two subagents dispatched in one message, each ~5 min) returned both in-turn with the switch and lost both without it. It is now our production setting for every unattended -p wave.
Minimal reproduction
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS= claude -p \
"Dispatch two general-purpose subagents in ONE message; each must run 'sleep 900; echo DONE' with Bash. Do not wait for them; reply 'dispatched' and end your turn." \
--output-format stream-json --verbose > run.jsonl
With a model that omits run_in_background (or when it passes true), run.jsonl shows two task_started with is_backgrounded: true, the process exits after ~10 minutes of no completions, both tasks end with task_notification status=stopped, result.subtype=success, exit code 0. With run_in_background: false (or the env switch set to 1) both return in-turn.
Redacted record shapes from the failing runs
{"type":"system","subtype":"task_started","task_type":"local_agent","is_backgrounded":true,"spawn_depth":1,"task_id":"…","tool_use_id":"…","session_id":"60294b47-…"}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"…","content":"Async agent launched successfully. …"}]}}
{"type":"system","subtype":"task_notification","status":"stopped","task_id":"…","tool_use_id":"…","output_file":"…","session_id":"60294b47-…"}
{"type":"result","subtype":"success","is_error":false,"num_turns":20,"duration_ms":1044000,"stop_reason":"end_turn","session_id":"60294b47-…"}
Asks
- A per-command foreground-only flag for
-p(the equivalent of the env switch), so unattended runs are deterministic regardless of what the model passes. - In
-pmode, refuse to exit while the session still owns unresolved background tasks: either wait for them (bounded by a configurable deadline) or end with a non-successresult(subtypeother thansuccess, non-zero exit code) that names the stopped tasks. Today a lost wave is indistinguishable from a clean run without parsingtask_notificationrecords.