Skip to content

Idle provider bridge processes are never retired for non-Codex providers #2308

Description

@ariofrio

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:

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.

Versions and environment

  • bb 0.39.0, macOS desktop app; macOS 26.5.1 arm64, 16 GB
  • provider-claude-code, Claude Code 2.1.239
  • 37 environments, 233 threads; bb daemon up 2 days
  • experiments.providerSessionReaping: true — the Idle agent processes are never reclaimed for non-Codex providers #1604 fix is enabled and working here (48 claude-code reap 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.

pgrep -f 'bb-provider-bridge-worker' | sort -n > /tmp/pre.txt
wc -l < /tmp/pre.txt                       # 35

bb thread spawn --project <proj> --environment <idle-env> \
  --provider claude-code --prompt "Reply with exactly the word: ok" --json

pgrep -f 'bb-provider-bridge-worker' | sort -n > /tmp/now.txt
NEW=$(comm -13 /tmp/pre.txt /tmp/now.txt)  # the new bridge
pgrep -P "$NEW"                            # its claude agent

bb thread stop <thread-id>

kill -0 "$NEW" && echo "ALIVE children=$(pgrep -P "$NEW" | wc -l)"
pgrep -f 'bb-provider-bridge-worker' | wc -l   # 36 — permanently one higher

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.

Root cause

releaseIdleProviderProcess gates the zero-threads shutdown on the process being Codex:

async function releaseIdleProviderProcess(proc: ProviderProcess): Promise<void> {
  if (isThreadScopedCodexProcess(proc) && proc.identity.threadIds.size === 0) {
    await providerProcesses.shutdownProvider({
      processKey: proc.processKey,
      providerId: proc.providerId,
    });
    return;
  }
  await providerProcesses.retireSupersededBridgeProcessIfIdle(proc);
}

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:

line path
957 thread/archive
1348 staged rewind cleanup
1558 failed session construction
2116, 2131 thread/stop — user-initiated and the idle reaper

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.

Proposed change

Drop the Codex guard:

async function releaseIdleProviderProcess(proc: ProviderProcess): Promise<void> {
  if (proc.identity.threadIds.size === 0) {
    await providerProcesses.shutdownProvider({
      processKey: proc.processKey,
      providerId: proc.providerId,
    });
  }
}

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.

What you ruled out

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions