Upstream issue draft — live CLI session torn down at run-end kills in-flight background subagents
Repo: openclaw/openclaw
Companion to: PR #99139 (fix(claude-cli): preserve reused session on empty response) and issue #99131.
Status: draft for Josh to file.
Summary
Follow-up to #99131 (which we reported). #99139 fixes the session-binding half of #99131;
this issue is the process-teardown half that #99139 leaves open — the part that actually kills
in-flight background subagents.
When a claude-cli-backed turn ends, OpenClaw force-closes the reused "live session"
(closeClaudeLiveSessionForContext → closeLiveSession(session,"restart") →
session.managedRun.cancel()), which cancels that CLI process and its whole tree. Any
background subagents (Task/Agent tool with run_in_background) that the ending turn spawned are
driven by that same live-session process, so they are stranded/killed the instant the turn ends —
they cannot outlive the turn that created them. Elapsed wall-clock is irrelevant: with the driver
gone, a "background" subagent makes zero further progress and is later reaped.
This is the process-layer half of the same family as #99131/#99139. #99139 correctly stops the
persisted session binding from being cleared on empty_response; it does not touch the process
teardown, so the subagent kill (and the self-reinforcing notification-preemption cascade) persists.
Repro
- In a session, spawn ≥1 background subagent (
run_in_background: true).
- End the turn (normal reply,
sessions_yield, or let a resumed turn return empty via a queued
task-notification).
- Observe: the subagent processes stop making progress and are torn down; on the interactive path
the gateway logs claude live session close: … reason=restart at every turn-end (even on
successful turns), confirming cleanupCliLiveSessionOnRunEnd:true closes the live session
unconditionally.
Root cause (from dist trace, 2026.6.11)
cli-runner's runCliAgentInternal: run-end cleanup calls closeClaudeLiveSessionForContext
unconditionally when params.cleanupCliLiveSessionOnRunEnd === true, with no awareness of
runError or of any still-running background children.
claude-live-session's session object has no in-flight/background-children counter — only a
single currentTurn slot (scheduleIdleClose guards on !session.currentTurn, which never
reflects a background child) and CLAUDE_LIVE_IDLE_TIMEOUT_MS = 600000 (hardcoded 10-min idle
close, no env override).
Proposed fix (the real one)
Introduce a live-children refcount on the session (increment when a background subagent/child run
attaches to the live session, decrement on its completion) and make all three teardown paths
"drain before close":
- run-end cleanup (
cleanupCliLiveSessionOnRunEnd) — skip/defer while liveChildren > 0;
scheduleIdleClose — extend the guard from !session.currentTurn to
!session.currentTurn && liveChildren === 0;
closeLiveSession(reason) — for "idle"/"restart" (not "abort"/error-driven), no-op while
liveChildren > 0 and re-arm a drain check.
Secondary: make CLAUDE_LIVE_IDLE_TIMEOUT_MS env-configurable.
Interim local mitigations we run (hand-patches, reapplied after each upgrade)
Note
shouldClearReusedCliSessionAfterError returns true for AbortError, so a genuine user
interrupt still clears the session binding (fresh context). If that's undesirable for
interactive sessions, consider sparing AbortError too, or making it configurable.
Upstream issue draft — live CLI session torn down at run-end kills in-flight background subagents
Repo: openclaw/openclaw
Companion to: PR #99139 (
fix(claude-cli): preserve reused session on empty response) and issue #99131.Status: draft for Josh to file.
Summary
Follow-up to #99131 (which we reported). #99139 fixes the session-binding half of #99131;
this issue is the process-teardown half that #99139 leaves open — the part that actually kills
in-flight background subagents.
When a
claude-cli-backed turn ends, OpenClaw force-closes the reused "live session"(
closeClaudeLiveSessionForContext→closeLiveSession(session,"restart")→session.managedRun.cancel()), which cancels that CLI process and its whole tree. Anybackground subagents (Task/Agent tool with
run_in_background) that the ending turn spawned aredriven by that same live-session process, so they are stranded/killed the instant the turn ends —
they cannot outlive the turn that created them. Elapsed wall-clock is irrelevant: with the driver
gone, a "background" subagent makes zero further progress and is later reaped.
This is the process-layer half of the same family as #99131/#99139. #99139 correctly stops the
persisted session binding from being cleared on
empty_response; it does not touch the processteardown, so the subagent kill (and the self-reinforcing notification-preemption cascade) persists.
Repro
run_in_background: true).sessions_yield, or let a resumed turn return empty via a queuedtask-notification).
the gateway logs
claude live session close: … reason=restartat every turn-end (even onsuccessful turns), confirming
cleanupCliLiveSessionOnRunEnd:truecloses the live sessionunconditionally.
Root cause (from dist trace, 2026.6.11)
cli-runner'srunCliAgentInternal: run-end cleanup callscloseClaudeLiveSessionForContextunconditionally when
params.cleanupCliLiveSessionOnRunEnd === true, with no awareness ofrunErroror of any still-running background children.claude-live-session's session object has no in-flight/background-children counter — only asingle
currentTurnslot (scheduleIdleCloseguards on!session.currentTurn, which neverreflects a background child) and
CLAUDE_LIVE_IDLE_TIMEOUT_MS = 600000(hardcoded 10-min idleclose, no env override).
Proposed fix (the real one)
Introduce a live-children refcount on the session (increment when a background subagent/child run
attaches to the live session, decrement on its completion) and make all three teardown paths
"drain before close":
cleanupCliLiveSessionOnRunEnd) — skip/defer whileliveChildren > 0;scheduleIdleClose— extend the guard from!session.currentTurnto!session.currentTurn && liveChildren === 0;closeLiveSession(reason)— for"idle"/"restart"(not"abort"/error-driven), no-op whileliveChildren > 0and re-arm a drain check.Secondary: make
CLAUDE_LIVE_IDLE_TIMEOUT_MSenv-configurable.Interim local mitigations we run (hand-patches, reapplied after each upgrade)
empty_response).cli-runnerskips the run-end force-close whenrunErroris aFailoverErrorwith
reason === "empty_response"(script:scripts/ops/openclaw-patches/apply-cli-runner-empty-response-keep-live-session.js). Thisneutralizes the empty_response cascade but does not implement the full drain-before-close, so
background subagents still don't survive a normal successful turn-end. Hence this issue.
Note
shouldClearReusedCliSessionAfterErrorreturnstrueforAbortError, so a genuine userinterrupt still clears the session binding (fresh context). If that's undesirable for
interactive sessions, consider sparing
AbortErrortoo, or making it configurable.