[BUG] print-mode background wait ceiling discards the cause of death, so killed agents resurface as unexplained orphans with the wrong recovery advice
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet. Closest matches are adjacent, not this bug: #89495 (same
-pwind-down neighbourhood, but scoped to an auto-backgrounded Bash command and to the false "you will be notified" promise — not to what the ceiling records when it kills); #73453 (closed/stale — the ceiling failing to apply at all, the opposite symptom); #64627 (closed/stale — Workflow runs killed at a ~13 min task timeout with no signal reaching the parent, a different tool and a different timeout). None covers what the print background wait ceiling writes — or fails to write — when it terminates an Agent task. - [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code (2.1.278)
What's Wrong?
When claude -p reaches its background wait ceiling (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, default 600000 ms) it terminates the still-running background agents. That part is documented and intentional.
The bug is that the ceiling branch terminates the task without recording why, while the branch immediately adjacent to it settles the task explicitly. The cause of death is discarded at the exact point where it is known.
Nothing fills that gap until the session is next opened, at which point the orphan reconciler — which has no idea what happened — synthesises a settlement and guesses:
Background agent "<description>" didn't finish before the previous session endedNo completion record was found for it in the previous session. It may have been stopped, or it may have been running when the previous Claude Code process exited … Resume it by sending it a message with SendMessage …
Every clause there is a guess about something the harness knew for certain 600 seconds earlier. The ceiling is never named, and neither is CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS — so the user is pointed at SendMessage recovery when the actual remedy for this cause is an environment variable they are never told exists.
To be explicit about what this is not: the killed tasks do eventually receive a terminal task_notification with status: "stopped". I checked — all of the affected agents in my store have one. But that notification is the orphan warning above, manufactured by the next session's reconciler. It is not written at kill time and carries no knowledge of the ceiling. This is a report about losing the cause, not about losing the settlement.
A secondary symptom of the same kill, in the same code path: the terminated agent's final transcript records are attributed to the user (see Error Messages below).
What Should Happen?
The ceiling branch should settle the task at termination time the way its adjacent branch already does, with a status/summary naming the ceiling — so the next session can say something like "stopped at the print background wait ceiling; raise CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS" instead of enumerating causes it does not need to guess at.
Concretely:
- Call the same settlement helper from the ceiling branch, with a cause that survives to the next session.
- Have the resulting notification name
CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, since that is the remedy for this particular cause. - Consider a distinct
toolDenialKindfor a host-initiated wind-down, so the cancellation is not recorded as a user rejection.
Error Messages/Logs
# The terminated agent's final transcript records — no human was present:
{ "toolDenialKind": "user-rejected", "toolUseResult": "User rejected tool use" }
{ "type": "user", "message": { "content": [
{ "type": "text", "text": "[Request interrupted by user for tool use]" } ] } }
# Meanwhile the host writes the real reason to stderr, where a non-interactive
# caller rarely sees it:
Background tasks still running after 600s; terminating. Set CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 to wait indefinitely.
# From the shipped binary (2.1.278). The ceiling branch and the branch next to it:
else if (s && V2(g) && g.status === "running")
t(`print wind-down: killing background ${g.type} task ${g.id} ("${g.description}") at the wait ceiling`), $_(g, r, n);
else {
t(`print wind-down: no longer waiting on background ${g.type} task ${g.id}`), In(g) && g.spawnedSubagent?.killed("system");
Ys(g.id, "stopped", { toolUseId: g.toolUseId, summary: g.description, ambient: sq(g) })
}
# ...and the extracted ceiling-kill helper in full (134 chars). It kills and returns:
async function $_(e,r,n){try{if(e.type==="local_agent")Px(e.id,r),tjt(e.id);await uot(e.type)?.kill(e.id,r,n,"system")}catch(s){d(s)}}
# occurrences inside it of: Ys( -> 0 | "stopped" -> 0 | task_notification -> 0 | toolUseId -> 0
# For reference, the settlement helper the adjacent branch calls:
function Ys(e, n, r) { … ml({ type: "system", subtype: "task_notification",
task_id: e, tool_use_id: r?.toolUseId, status: n, … }) }
The asymmetry has survived at least three releases and a refactor: the enclosing function is Fm on 2.1.272, Vm on 2.1.274, and on 2.1.278 the ceiling branch has been extracted into the standalone $_() helper above — still with no settlement call. (The settlement helper is Ii / Xs / Ys across the same three versions; the renames are the minifier's.)
Steps to Reproduce
- Have a background session reach a terminal state while waiting on user input.
- Reply to it. The reply is delivered as a print-mode resume (
entrypoint: "sdk-cli"in the transcript), not as a live REPL. - During that turn, dispatch a background agent (
Agenttool,run_in_background: true) whose work will take longer than the ceiling. - The turn ends. Exactly 600 s later the agent is terminated and nothing records why.
- Re-open the session. It reports the agent "didn't finish before the previous session ended", enumerates causes it need not guess at, and recommends
SendMessagerecovery.
To compress step 4 for testing, set CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS to a few seconds; the wind-down then fires on the same path immediately.
Measured across my own session store, which is how I found this: 16 of 16 stranded background agents died 599–600 seconds after their parent session's turn ended, measured from the parent's system / stop_hook_summary record, with the parent at entrypoint: "sdk-cli" every time. A 600-second interval reproduced to the second across 16 independent events is the ceiling, not coincidence.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.278
Operating System
macOS
Terminal/Shell
Non-interactive/CI environment
Additional Information
The ceiling is liveness-blind. It measures the host's idleness, not the task's progress. In one of the 16 cases the agent had written 227 records in its final 600 seconds (maximum gap between records: 49 s) and was killed 1 second after its last write. A demonstrably productive agent is swept because the main thread has been parked. A stall-based condition would not have fired on any of the 16.
Context on how users reach this path without realising they are in print mode (mentioned as context, not as a second bug report): all 16 parents in my sample were daemon-backed background sessions that had already reached a terminal state. Replying to a stopped background session resumes it headlessly, so the reply's turn runs under the print ceiling and anything that turn dispatches silently inherits a 10-minute cap. From the user's point of view they are talking to a background agent, not running a headless script — and respawnFlags / claude respawn suggest the machinery to bring the daemon REPL back already exists.
All local identifiers (paths, session ids, agent ids, task descriptions) have been omitted deliberately; happy to supply redacted transcript excerpts if useful.