[BUG] Background daemon never reaps stale workers or unclaimed spares — they are re-adopted on every restart (64 leaked processes / ~7.1 GB after six weeks)

Status Open
Reported on v2.1.235
Maintainer reply ✓ Yes — claude[bot]
Activity 3 comments · opened Aug 19, 2026
💡 Likely answer: A maintainer (claude[bot], contributor) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

The background-session daemon never reaps stale workers or unclaimed spares. Worse, on every self-restart it re-adopts them, so the dead weight is inherited indefinitely rather than cleared. After six weeks of normal use I had 64 leaked claude processes holding ~7.1 GB RSS, none of which had done anything for 25+ days.

The daemon's own log shows it knows they are stale and declines to clean them up. From today, on 2.1.235, immediately after a routine upgrade restart:

[2026-08-19T05:24:49.885Z] [supervisor] binary at ~/.local/bin/claude changed (2.1.233 → 2.1.235) — self-restarting for upgrade
[2026-08-19T05:24:49.908Z] [supervisor] shutting down (cause=upgrade, uptime=170930s, leases=0, live_workers=32)
[2026-08-19T05:24:50.154Z] [supervisor] ─── daemon start ─── version=2.1.235 pid=70841 origin=transient
[2026-08-19T05:24:50.188Z] [bg] bg adopt: adopted=32 respawned=0 dead=0
[2026-08-19T05:24:50.191Z] [bg] bg orphan-spare reap: 1
[2026-08-19T05:25:05.208Z] [bg] bg: post-takeover prewarm burst — respawned 0/32 stale workers, 32 refused in 0s

adopted=32 ... dead=0 — the new daemon takes ownership of 32 workers and classifies none of them as dead. Fifteen seconds later it tries to respawn them, all 32 refuse, and it calls them "stale" in its own log message. Nothing then removes them. orphan-spare reap: 1 is the only reclamation that happens, and it reclaims a single process.

This is not new to 2.1.235 — the identical shape appears at every upgrade restart in my log going back weeks, e.g. 2.1.217→2.1.218 (adopted=33 respawned=0 dead=0, orphan-spare reap: 1, respawned 1/33 stale workers, 32 refused).

Breakdown of what leaked

64 processes, age ≥ 25 days, ~7.1 GB RSS:

  • 22 processes = 11 wedged background session workers (a bg-pty-host plus its --session-id … --fork-session --resume … child), ~2.8 GB. Each reports to claude agents --json as background / blocked / idle, i.e. still "active". Ten of the eleven never wrote a transcript at all; the eleventh wrote a 0-byte file. They are running binaries from 2.1.202–2.1.218 that have long since been superseded.
  • The remainder are pre-warmed spares and their pty hosts that were spawned, never claimed, and never released.

A worked example, traceable end-to-end in the log. Session 6ffcdf0e was spawned, and 17 minutes later an upgrade restart occurred:

[2026-07-22T05:40:40.860Z] [bg] bg spawned 6ffcdf0e (slash)
[2026-07-22T05:40:40.865Z] [bg] bg spare spawned host pid=92993
[2026-07-22T05:58:39.222Z] [supervisor] binary changed (2.1.216 → 2.1.217) — self-restarting for upgrade
[2026-07-22T05:58:39.472Z] [supervisor] ─── daemon start ─── version=2.1.217 pid=67516 origin=transient
[2026-07-22T05:58:39.472Z] [supervisor] ─── daemon start ─── version=2.1.217 pid=67499 origin=transient
[2026-07-22T05:58:39.486Z] [supervisor] another daemon won the lock race (pid=67499) — exiting
[2026-07-22T05:58:39.507Z] [supervisor] ─── daemon start ─── version=2.1.217 pid=67520 origin=transient
[2026-07-22T05:58:39.508Z] [supervisor] ─── daemon start ─── version=2.1.217 pid=67521 origin=transient
[2026-07-22T05:58:39.519Z] [supervisor] another daemon is already running (pid=67499, …)
[2026-07-22T05:58:39.531Z] [bg] bg adopt: adopted=34 respawned=0 dead=0

There is no bg settled 6ffcdf0e line anywhere in the log, before or since. It was adopted by the lock-race winner and never settled. Its process pair is still resident 28 days later.

Two things stand out in that excerpt beyond the leak itself:

  1. Four daemons are launched simultaneously and race for one lock. Three lose and exit. This is presumably benign by design, but it is the moment the worker's ownership is lost.
  2. A worker adopted during that handover can end up with no lifecycle owner — never settled, never respawned, never reaped, but still counted as an active background session by claude agents.

Steps to Reproduce

  1. Use background agents regularly (claude --bg, the Agent tool, /bg) across several repos, over a period of weeks, on a machine that stays powered on.
  2. Let Claude Code auto-update a few times so the daemon performs its upgrade self-restart.
  3. Inspect the resident process set: ps -axo pid,ppid,etime,rss,command | grep claude
  4. Inspect ~/.claude/daemon.log for bg adopt:, orphan-spare reap: and post-takeover prewarm burst lines.

Expected Behavior

  • Workers that refuse respawn and are classified as stale during the post-takeover burst are terminated, not carried forward.
  • A worker adopted during a daemon handover that never re-attaches to a client is eventually settled and reaped, and stops being reported as an active background session.
  • Unclaimed pre-warm spares have a bounded lifetime.

Actual Behavior

Stale workers and unclaimed spares accumulate without limit and are re-adopted on every daemon restart. On this machine that reached 64 processes and ~7.1 GB, with 11 of them reported indefinitely as live background / blocked / idle sessions despite having written no transcript and being weeks dead. Manually killing them is currently the only remedy.

Environment

  • macOS, Apple Silicon (arm64)
  • Claude Code 2.1.235 (native CLI install under ~/.local/share/claude/versions/); leaked processes span 2.1.202 – 2.1.218
  • Daemon is origin: transient per ~/.claude/daemon.lock
  • Usage pattern: many concurrent background agents across multiple repositories, machine rarely rebooted

Related

  • #78454 — stale worker entries in roster.json surviving process death (the ledger-side counterpart; this report is the process side)
  • #87813 — background shell processes not cleaned up on session exit (different subsystem: Bash tasks, not daemon workers/spares)

View original on GitHub ↗

3 Comments

claude[bot] contributor · 1 month ago

Confirmed / reproduced on Linux, the stale-worker half: background daemon started from 2.1.233 with two claude --bg sessions, then the binary replaced in place by 2.1.235. The daemon logged binary ... changed (mtime changed) — self-restarting for upgrade, then bg adopt: adopted=2 respawned=0 dead=0, bg orphan-spare reap: 1, and 15 s later post-takeover prewarm burst — respawned 0/2 stale workers, 2 refused in 0s — the same signature as your log. The two 2.1.233 workers and their pty hosts survived that restart and a second forced one, still running the old binary, and claude agents --json --all kept listing them as background / idle / blocked; nothing terminated or respawned them.

Difference here: the unclaimed pre-warmed spare WAS reaped on each restart (orphan-spare reap: 1 and the spare process gone), so the spare-accumulation half did not reproduce on Linux in a short run. Also noticed while cleaning up: claude daemon stop --any stopped the daemon and workers but left the bg-pty-host processes running.

🤖 Generated with Claude Code

---
_Generated by Claude Code_

christian-schauer · 1 month ago

Same signature on Windows 11 (npm-global, 2.1.237), so this is not macOS/Linux-specific. Three additions below that may narrow the fix — in particular, a reaper does exist, it is just gated on the wrong condition.

Signature reproduces

[03:10:21.748Z] [bg] bg adopt: adopted=4 respawned=0 dead=0
[03:10:36.866Z] [bg] bg: post-takeover prewarm burst — respawned 0/2 stale workers, 2 refused in 0s
[04:10:24.179Z] [bg] bg adopt: adopted=4 respawned=0 dead=0
[04:10:39.277Z] [bg] bg: post-takeover prewarm burst — respawned 0/2 stale workers, 2 refused in 0s
[05:10:26.316Z] [bg] bg adopt: adopted=4 respawned=0 dead=0
[05:10:41.418Z] [bg] bg: post-takeover prewarm burst — respawned 0/2 stale workers, 2 refused in 0s

dead=0 every time, refused every time, nothing removed — identical to the OP.

1. A reaper exists, but only memory pressure triggers it

This is the part I had not seen mentioned. The daemon can retire workers, and says so:

[08:29:32.059Z] [bg] bg: low memory (782MB free) — retiring settled workers before spawning b311b097
[08:29:35.672Z] [bg] bg: low memory (550MB free) — retiring settled workers before spawning e8477c34
[08:29:41.697Z] [bg] bg: low memory ( 94MB free) — retiring settled workers before spawning 9e160fca

So the machinery is there; its trigger is just narrow on two axes — it fires only under memory pressure, and only for workers already settled. The workers that post-takeover prewarm burst has itself just classified as stale are never handed to it. Widening that one call site may be a smaller change than building a new reaper.

2. Eviction is recorded as settled (done), indistinguishable from success

Directly after that third line, with 94 MB free:

[08:29:41.697Z] [bg] bg spawned 9e160fca (fleet)
[08:29:41.807Z] [bg] bg settled 9e160fca (done)

110 ms between spawn and (done). The worker never ran; it was evicted so the next spawn could proceed. But the log records the same (done) a genuinely finished session gets, so nothing downstream can tell the two apart — the transcript is empty either way and claude agents reports it as finished.

This has a practical consequence for anyone building cleanup tooling around this issue. A watchdog here treats settled … (done) as "finished, do not touch" (deliberately — respawning genuinely finished sessions caused a revive loop previously). That rule is correct for real completions and silently wrong for evictions, and from the log alone the distinction cannot be made. A separate cause on the settle line (evicted / memory) would fix that for every consumer.

Related: this compounds the "failed sessions shown under Completed" point in #83715 — here it is the log itself that loses the distinction, one layer below the view.

3. On Windows the leak can be unkillable and unreadable from the user's own session

Two leaked workers here were 60 h old, ~187 MB, in Windows session 0, and their supervisor's parent was long gone. The OP's remedy ("manually killing them") needs a qualifier on Windows: if the respawn that created them was issued from an elevated process — e.g. a Scheduled Task with Run with highest privileges, which is a natural place to put a watchdog — the workers inherit high integrity. From the user's normal (medium-integrity) session:

  • Stop-Process -Id <pid> -Force → Access is denied
  • OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) → error 5, so not even the image path is readable
  • WMI returns an empty ExecutablePath and CommandLine for them

So they cannot be cleaned up, and cannot even be identified as Claude processes, without an elevated shell. Any documented cleanup step for Windows should say so. This also makes third-party cleanup filters that key on ExecutablePath silently no-op against exactly the processes that leaked.

(The same integrity mismatch has a second effect worth knowing when placing a watchdog: while an elevated daemon holds the control pipe, the medium-integrity desktop app cannot reach it at all — UIPI blocks Medium→High — so claude --bg from the app times out until that daemon is gone.)

Environment

  • Windows 11 Pro 26200, x64, 16 GB
  • Claude Code 2.1.237 → 2.1.236 (npm-global); leaked workers span older builds
  • Daemon origin: transient; 4–9 concurrent background sessions, machine runs 24/7

---
_Investigated with Claude Code._

marcindulak · 20 hours ago

Please note that the label reproduced on this issue does not shield it from becoming stale and eventually auto-closed.
To prevent this you would need to collect at least 10 thumbs up or comment every time before the 14 days stale interval expires.
See https://github.com/anthropics/claude-code/issues/87647 for details, and consider upvoting that for visibility.