You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When the last thread on a provider bridge process is released, the bridge is shut down only if it is a thread-scoped Codex process. Claude Code and every ACP provider leave the bridge resident with zero threads attached, ~78 MB each, until the daemon exits.
Non-Codex bridges are keyed per environment, so the retained set grows with environment count, not activity.
Concretely, on one 16 GB machine as I write this:
bb daemon uptime
2 days 0 h
agent threads started in that window
21, all claude-code, across 20 environments
environments on the machine
37
bridge processes
29 — of which 12 own zero threads
memory held by those 12
960 MB
oldest stranded bridge
idle 1 day 2 h, 5 s of lifetime CPU
Twelve of 29 bridges are serving nothing, and the oldest has been serving nothing for over a day. The counts drift as agents come and go, so treat this as a snapshot rather than a steady state — but the stranded set only grows between daemon restarts.
Sibling of #1604 — that issue covered the agent process and its reaper now works. This is the bridge process that hosted it, which the same release path deliberately walks past.
Note that bb thread archive is not equivalent: archiving a loaded thread leaves the session — and its agent process — running, so the bridge keeps a thread and never reaches the guard's zero-thread condition. Stranding happens later, when the idle reaper stops that session. Verified: archiving thr_t39und5uud left bridge 75410 alive with children=1 and its agent alive 2 minutes later.
Expected vs actual
Expected: releasing the last thread on a bridge retires it, as it already does for Codex.
Actual: the bridge stays resident. Controlled run — thread thr_d4mrw85puf, environment env_cczzs6ni7w, bridge pid 1649, agent pid 1679:
before stop: bridge 1649 children=1 claude 1679 alive
$ bb thread stop thr_d4mrw85puf
Thread thr_d4mrw85puf stopped
+2s bridge1649=ALIVE children=0 claude1679=GONE
+90s bridge1649=ALIVE children=0 age=02:47
pid 1649 footprint: 78M
bridges: 35 before spawn -> 36 after stop
Across the machine, twelve bridges are stranded this way — ages 57 min to 1 day 2 h, each with single-digit seconds of lifetime CPU, totalling 960 MB.
The fallback, retireSupersededBridgeProcessIfIdle, returns early unless the bridge's artifact hash is superseded (if (currentHash === undefined || currentHash === parts.hash) return;). It exists only to clean up bridges orphaned by a plugin update. A bridge on the current artifact with zero threads matches no retirement path at all.
The per-environment multiplicity comes from resolveProviderProcessKey, which gives Codex a thread-scoped key and everything else a provider-scoped one — one bridge per runtime, and runtimes are keyed by environment (runtime-manager.ts:310).
All five callers route through that guard, so none of them can retire a non-Codex bridge:
Of these, thread/stop is the path I verified end-to-end (both user-initiated and via the reaper, which calls stopThread); the other three are read from source. thread/archive in particular does not release the session itself, so it defers stranding to the reaper rather than causing it directly.
Line 1558's own comment states the intent the guard defeats: "drop the thread's runtime state and stop a thread-scoped process so the failure cannot leak an idle provider under the daemon." For non-Codex providers that path leaks a bridge on every failed session start.
Safe and self-healing: ensureProvider already respawns a missing or exited process transparently, and every caller awaits it before issuing a command.
It also removes a special case: retireSupersededBridgeProcessIfIdle returns early whenever threadIds.size > 0, so "zero threads → shut down" fully subsumes it and the helper can go. retireStaleBridgeProcesses must stay — it handles processes that never owned a thread.
Cost: a bridge dies only at deliberate end-of-work boundaries (a turn ending does not release a thread), so the penalty is one respawn — measured at 0.66–1.22 s for the 2.5 MB host.js import, plus initialize — if more work follows in that environment. If that proves too eager, the same change plus a short grace timer when threadIds reaches 0 keeps the warmth, mirroring the existing providerMaintenanceIdleTimer pattern.
Not a warm pool — no pool would retain a member idle over a day with 5 s of lifetime CPU, and the count tracks environments, not any bound.
Not artifact churn — all bridges reference the same hash af84d115…, so none is superseded and the existing retirement path can never fire.
Searched open and closed issues for "provider bridge worker", "evict idle environment", "runtime entry never released", "orphan", "leak" — nothing covers this.
Suggested priority and effort
Low effort: delete a condition, remove the helper it subsumes, update tests. Moderate impact: 960 MB reclaimed here right now, and it removes a floor that grows with every environment (~2.9 GB at 37 environments if all go idle). No data or work is lost; the workaround is restarting bb.
AGENT GENERATED: by Claude Opus 5, bb thread thr_ip5mnehwg7
Summary
When the last thread on a provider bridge process is released, the bridge is shut down only if it is a thread-scoped Codex process. Claude Code and every ACP provider leave the bridge resident with zero threads attached, ~78 MB each, until the daemon exits.
Non-Codex bridges are keyed per environment, so the retained set grows with environment count, not activity.
Concretely, on one 16 GB machine as I write this:
claude-code, across 20 environmentsTwelve of 29 bridges are serving nothing, and the oldest has been serving nothing for over a day. The counts drift as agents come and go, so treat this as a snapshot rather than a steady state — but the stranded set only grows between daemon restarts.
Sibling of #1604 — that issue covered the agent process and its reaper now works. This is the bridge process that hosted it, which the same release path deliberately walks past.
Versions and environment
provider-claude-code, Claude Code2.1.239experiments.providerSessionReaping: true— the Idle agent processes are never reclaimed for non-Codex providers #1604 fix is enabled and working here (48claude-codereap events in~/.bb/logs/host-daemon*.log, 0 before)Steps to reproduce
One spawn/stop cycle in an environment with no loaded runtime permanently adds one bridge process.
Note that
bb thread archiveis not equivalent: archiving a loaded thread leaves the session — and its agent process — running, so the bridge keeps a thread and never reaches the guard's zero-thread condition. Stranding happens later, when the idle reaper stops that session. Verified: archivingthr_t39und5uudleft bridge75410alive withchildren=1and its agent alive 2 minutes later.Expected vs actual
Expected: releasing the last thread on a bridge retires it, as it already does for Codex.
Actual: the bridge stays resident. Controlled run — thread
thr_d4mrw85puf, environmentenv_cczzs6ni7w, bridge pid1649, agent pid1679:Across the machine, twelve bridges are stranded this way — ages 57 min to 1 day 2 h, each with single-digit seconds of lifetime CPU, totalling 960 MB.
Root cause
releaseIdleProviderProcessgates the zero-threads shutdown on the process being Codex:The fallback,
retireSupersededBridgeProcessIfIdle, returns early unless the bridge's artifact hash is superseded (if (currentHash === undefined || currentHash === parts.hash) return;). It exists only to clean up bridges orphaned by a plugin update. A bridge on the current artifact with zero threads matches no retirement path at all.The per-environment multiplicity comes from
resolveProviderProcessKey, which gives Codex a thread-scoped key and everything else a provider-scoped one — one bridge per runtime, and runtimes are keyed by environment (runtime-manager.ts:310).All five callers route through that guard, so none of them can retire a non-Codex bridge:
thread/archivethread/stop— user-initiated and the idle reaperOf these,
thread/stopis the path I verified end-to-end (both user-initiated and via the reaper, which callsstopThread); the other three are read from source.thread/archivein particular does not release the session itself, so it defers stranding to the reaper rather than causing it directly.Line 1558's own comment states the intent the guard defeats: "drop the thread's runtime state and stop a thread-scoped process so the failure cannot leak an idle provider under the daemon." For non-Codex providers that path leaks a bridge on every failed session start.
Proposed change
Drop the Codex guard:
Safe and self-healing:
ensureProvideralready respawns a missing or exited process transparently, and every caller awaits it before issuing a command.It also removes a special case:
retireSupersededBridgeProcessIfIdlereturns early wheneverthreadIds.size > 0, so "zero threads → shut down" fully subsumes it and the helper can go.retireStaleBridgeProcessesmust stay — it handles processes that never owned a thread.Cost: a bridge dies only at deliberate end-of-work boundaries (a turn ending does not release a thread), so the penalty is one respawn — measured at 0.66–1.22 s for the 2.5 MB
host.jsimport, plusinitialize— if more work follows in that environment. If that proves too eager, the same change plus a short grace timer whenthreadIdsreaches 0 keeps the warmth, mirroring the existingproviderMaintenanceIdleTimerpattern.What you ruled out
af84d115…, so none is superseded and the existing retirement path can never fire.Suggested priority and effort
Low effort: delete a condition, remove the helper it subsumes, update tests. Moderate impact: 960 MB reclaimed here right now, and it removes a floor that grows with every environment (~2.9 GB at 37 environments if all go idle). No data or work is lost; the workaround is restarting bb.