#1647 · Deleting a worktree environment leaves its processes running
TL;DR
When bb destroys a managed worktree environment (delete the last thread, or archive it and wait out the 5-minute grace window), the host daemon does three things: shuts down the runtime, closes the environment's terminals, and runs git worktree remove --force. The shutdown step signals only the direct children the daemon spawned: the provider bridge process gets SIGTERM (then SIGKILL after 5 s) and each terminal pty gets SIGHUP. Nothing bb does reaches anything those children started that detached from them — a nohup … & dev server from an agent's Bash tool, a background job in a bb terminal, an MCP server left over when the bridge is force-killed. Those survive with a working directory that no longer exists (/proc/<pid>/cwd → "… (deleted)") and nothing ever reaps them.
I reproduced this on Linux with the current main in two independent ways: (A) a claude-code thread that ran nohup sleep 3000 & via its Bash tool — after the thread was deleted and the environment reported destroyed, the sleep was still alive with a (deleted) cwd; (C) a bb terminal in the environment running nohup python3 -m http.server 18647 & — after destroy the server still answered HTTP on the port from a deleted directory. A unit test at the exact code path (RuntimeProviderProcessManager.shutdown()) fails on main and passes on PR #1696. Note that in the common happy path the provider CLI itself is cleaned up (bridge handles SIGTERM gracefully and takes claude with it), so the 12h-old claude in the duplicate report requires the force-kill path or a daemon restart, which I could not provoke deterministically.
PR #1696 fixes the root cause on the daemon side (spawn provider CLIs and setup scripts as process-group leaders and signal the group; sweep and kill every process whose cwd is inside the managed workspace before removing it) and its tests pass. My live retest on the PR branch shows all three leaked processes reaped. I still recommend REQUEST CHANGES for a few small things: the sweep also kills processes bb never started (I demonstrated a "user shell" rooted in the worktree being SIGTERM/SIGKILLed), the whole-group SIGTERM bypasses the bridge's graceful shutdown ordering, and the PR description is stale about ACP agents.
Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Deleting a worktree environment removes the directory but does not kill processes started inside it | Verified | Repro A and C below; destroyEnvironment only calls runtime.shutdown() (SIGTERM to the bridge pid) and workspace.destroy(); terminals get pty.kill() (SIGHUP to the shell pid). No sweep, no group signal (Root cause). |
| Survivors have a cwd that no longer exists and nothing reaps them | Verified | /proc/1490762/cwd → …/env_i9cqm9wn4t/1647-qa (deleted); still alive minutes later until I killed it. There is no reaper in the daemon (grep -rn cwd apps/host-daemon/src finds no sweep on main). |
| Reproduction: dev server started in the env keeps running after delete | Verified | Repro C: python3 -m http.server 18647 from a bb terminal survives destroy and still answers (HTTP 404 because its root dir is gone). |
| 40 environments / 190 processes / 1.75 GB RSS / swap 94% on macOS | Unverifiable | Reporter's machine; mechanism consistent with what I reproduced. macOS uses the same daemon code path (no platform branch in destroyEnvironment). |
A claude provider process alive 12h46m after destroy (#1769) | Plausible, not reproduced | In my runs (idle and mid-turn) the claude-code bridge handled SIGTERM gracefully and claude exited with it. It would survive when the bridge is SIGKILLed after the 5 s deadline (only the bridge pid is killed; claude is a grandchild), or when the daemon that spawned it restarted before destroy (a re-attached daemon has no handle to the old bridge at all). Both are real code paths on main; I did not provoke them live. |
| Suggested fix: kill the process group on teardown | Partially sufficient | Group signalling covers same-group grandchildren (my unit repro) but not new-session processes (nohup/setsid, claude-code's Bash tool children — those were not in the bridge's group in my PR-branch run and were only caught by the cwd sweep, see PR daemon log). |
Environment
- bb
16ceb3a54(main, 2026-08-18); own dev instance from this worktree: app:11440, server:19440, host daemon:27440, data dir/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183 - Linux 7.0.0-29-generic, node v24.18.0, Claude Code 2.1.234 (provider
claude-code), codex-cli 0.147.0 (unused) - Project
proj_ymakza7kkq(local path/tmp/1647-qa, hosthost_cfsw4qq4t3) - CLI wrapper: 1647/repro/1647-bb.sh; process lister: 1647/repro/1647-procs.sh (prints pid, ppid, cwd, cmdline for every process whose cwd is at/under a directory, including
(deleted)) - PR #1696 tested at head
b3f6d2e7d(fetched viarefs/pull/1696/head).
Minimal reproduction
Precondition: dev instance up (scripts/bb-dev-app current), project created with the curl from the brief. All commands use the wrapper /tmp/1647-bb.sh.
A. Agent-started background process survives environment destroy (main)
$ /tmp/1647-bb.sh thread spawn --project proj_ymakza7kkq --new-environment worktree \
--provider claude-code --permission-mode full --title "1647 repro" \
--prompt 'Run exactly this shell command with your Bash tool: nohup sleep 3000 >/dev/null 2>&1 & disown ; then reply only with ok.' --json
# → thr_4z7cwus4xh, environment env_i9cqm9wn4t,
# path /home/sawyer/.bb-dev/…/worktrees/env_i9cqm9wn4t/1647-qa
# wait until `thread show` reports "status": "idle"
$ /tmp/1647-procs.sh /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_i9cqm9wn4t/1647-qa
1489631 ppid=1472667 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_i9cqm9wn4t/1647-qa /home/sawyer/.nvm/versions/node/v24.18.0/bin/node --conditions=source --import file:///home/sawyer/projects/bb
1489910 ppid=1489631 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_i9cqm9wn4t/1647-qa /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json --thinking ada
1490762 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_i9cqm9wn4t/1647-qa sleep 3000
# ↑ bridge (node, direct child of the daemon 1472667), claude (grandchild), and the
# agent's `sleep 3000` already reparented to pid 1 (ppid=1).
$ curl -s -X DELETE http://localhost:19440/api/v1/threads/thr_4z7cwus4xh \
-H 'content-type: application/json' -d '{"childThreadsConfirmed":true}'
{"ok":true}
$ sleep 8; ls -d …/env_i9cqm9wn4t/1647-qa
ls: cannot access '…/env_i9cqm9wn4t/1647-qa': No such file or directory
$ /tmp/1647-bb.sh environment show env_i9cqm9wn4t --json | grep -E '"status"|"path"'
"path": null,
"status": "destroyed",
$ /tmp/1647-procs.sh /home/sawyer/.bb-dev/…/worktrees/env_i9cqm9wn4t/1647-qa
1490762 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_i9cqm9wn4t/1647-qa (deleted) sleep 3000
Expected: after the environment is destroyed, no process is rooted in its directory. Actual: bridge and claude are gone, but the agent-started sleep 3000 is alive with cwd … (deleted). Raw files: before, after, thread log.
C. Terminal-started dev server survives (main)
$ /tmp/1647-bb.sh thread spawn --project proj_ymakza7kkq --new-environment worktree --provider claude-code \
--permission-mode full --title "1647 repro C" --prompt 'Reply only with ok.' --json # → thr_zrtk56q3s2 / env_cbjteb28gn
$ /tmp/1647-bb.sh terminal create --environment env_cbjteb28gn --json # → term_gsuk92nuzi
$ /tmp/1647-bb.sh terminal send term_gsuk92nuzi --text 'nohup python3 -m http.server 18647 >/tmp/1647-httpserver.log 2>&1 &' --enter
$ /tmp/1647-procs.sh …/worktrees/env_cbjteb28gn/1647-qa
1553763 ppid=1472667 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa /home/sawyer/.nvm/versions/node/v24.18.0/bin/node --conditions=source --import file:///home/sawyer/projects/bb
1554056 ppid=1553763 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json --thinking ada
1558312 ppid=1472667 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa /bin/bash
1562990 ppid=1558312 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa python3 -m http.server 18647
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18647/
200
$ curl -s -X DELETE http://localhost:19440/api/v1/threads/thr_zrtk56q3s2 -H 'content-type: application/json' -d '{"childThreadsConfirmed":true}'
Tue Aug 18 05:22:18 AM UTC 2026
{"ok":true}
--- t+3s env status: "status": "destroyed",
1562990 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa (deleted) python3 -m http.server 18647
--- t+6s env status: "status": "destroyed",
1562990 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa (deleted) python3 -m http.server 18647
--- t+9s env status: "status": "destroyed",
1562990 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa (deleted) python3 -m http.server 18647
--- t+12s env status: "status": "destroyed",
1562990 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa (deleted) python3 -m http.server 18647
--- t+15s env status: "status": "destroyed",
1562990 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_cbjteb28gn/1647-qa (deleted) python3 -m http.server 18647
http.server still answering on :18647 -> HTTP 404
Expected: the dev server dies with its environment. Actual: the bb terminal shell (bash 1558312) died from the pty SIGHUP, but the nohup'd server keeps serving from a deleted directory. Files: before, after.
B. Control: mid-turn foreground work is cleaned up (main)
Deleting a thread while claude was running sleep 90 in its Bash tool (a foreground child) left nothing behind: the bridge shut down gracefully, claude killed its own tool subprocess. So the leak is specifically about processes that detach from the provider tree (or survive a force-kill of it). Files: before, after.
Unit-level repro at the exact code path
File: 1647/repro/repro-1647-orphaned-grandchildren.test.ts. Drop it into packages/agent-runtime/src/ and run cd packages/agent-runtime && pnpm exec vitest run src/repro-1647-orphaned-grandchildren.test.ts. It starts a fake provider whose launcher backgrounds a sleep (stand-in for next dev) with cwd = workspace, then calls RuntimeProviderProcessManager.shutdown() — exactly what RuntimeManager.destroyEnvironment does before workspace.destroy(). On main it FAILS at line 140 (expect(isAlive(grandchildPid)).toBe(false)): the provider is dead, the grandchild is alive with cwd (deleted). On PR #1696 it passes (the provider is a group leader and the group gets the signal).
RUN v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_debcf606-e4a-9/packages/agent-runtime
stdout | src/repro-1647-orphaned-grandchildren.test.ts > #1647 provider shutdown leaves grandchildren running > kills the grandchild a provider process started in the workspace
provider pid 1734117 alive=false; grandchild pid 1734118 alive=true cwd=/tmp/bb-1647-ws-mPfixq (deleted)
❯ @bb/agent-runtime src/repro-1647-orphaned-grandchildren.test.ts (1 test | 1 failed) 39ms
× kills the grandchild a provider process started in the workspace 38ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL @bb/agent-runtime src/repro-1647-orphaned-grandchildren.test.ts > #1647 provider shutdown leaves grandchildren running > kills the grandchild a provider process started in the workspace
AssertionError: expected true to be false // Object.is equality
- Expected
+ Received
- false
+ true
❯ src/repro-1647-orphaned-grandchildren.test.ts:140:36
138| `grandchild pid ${grandchildPid} alive=${isAlive(grandchildPid…
139| );
140| expect(isAlive(grandchildPid)).toBe(false);
| ^
141| });
142| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed (1)
Start at 05:33:54
Duration 835ms (transform 437ms, setup 0ms, import 709ms, tests 39ms, environment 0ms)
/**
* Repro for get-bb/bb#1647: RuntimeProviderProcessManager.shutdown() only
* signals the direct provider child. A grandchild the provider started
* (dev server, MCP server, background job) survives with its cwd pointing at
* the workspace that destroyEnvironment is about to delete.
*
* Expected on main: FAILS at `expect(isAlive(grandchildPid)).toBe(false)`.
*/
import { readlinkSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RuntimeProviderProcessManager } from "./runtime-provider-process.js";
import { RuntimeThreadIdentityRegistry } from "./runtime-thread-identity.js";
import { buildNodeScriptArgs } from "./test/fake-adapter.js";
import { fakeProviderScriptPath } from "./test/index.js";
import { createFakeAdapter } from "./test/runtime-test-harness.js";
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => boolean,
timeoutMs = 8000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (!check()) {
if (Date.now() > deadline) return false;
await new Promise((r) => setTimeout(r, 25));
}
return true;
}
describe("#1647 provider shutdown leaves grandchildren running", () => {
const cleanupPids: number[] = [];
afterEach(() => {
for (const pid of cleanupPids) {
try {
process.kill(pid, "SIGKILL");
} catch {
// gone
}
}
});
it("kills the grandchild a provider process started in the workspace", { timeout: 30_000 }, async () => {
const workspacePath = mkdtempSync(join(tmpdir(), "bb-1647-ws-"));
const stderr: string[] = [];
const identityRegistry = new RuntimeThreadIdentityRegistry();
let nextRequestId = 1;
const base = createFakeAdapter(fakeProviderScriptPath);
// Provider = `sh` that backgrounds a `sleep` (stand-in for `next dev`),
// reports its pid on stderr, then execs the real fake provider script.
const adapter: typeof base = {
...base,
process: {
command: "sh",
args: [
"-c",
'sleep 300 & echo "grandchild=$!" >&2; exec node "$@"',
"sh",
...buildNodeScriptArgs(fakeProviderScriptPath),
],
},
buildCommandPlan(command) {
if (command.type === "initialize") {
return { kind: "noop", reason: "spawn only" };
}
return base.buildCommandPlan(command);
},
};
const manager = new RuntimeProviderProcessManager({
additionalWorkspaceWriteRoots: [],
adapterFactory: () => adapter,
bridgeBundleDir: undefined,
captureThreadExitState: (threadId) => ({
activeTurnId: null,
pendingTurnStart: false,
providerThreadId: null,
threadId,
}),
createProviderIdentityState: (providerId) =>
identityRegistry.createProviderState({ providerId }),
env: {},
getNextRequestId: () => nextRequestId++,
handleStdoutLine: () => undefined,
onProcessExit: vi.fn(),
onProviderIdentityWaitersInterrupted: (p) =>
identityRegistry.resolvePendingIdentityWaiters(p.identity),
onProviderThreadDetached: (threadId) =>
identityRegistry.clearThread(threadId),
onStderr: (line) => stderr.push(line),
skillRoots: [],
workspacePath,
});
await manager.ensureProvider({ processKey: "fake", providerId: "fake" });
const providerPid =
manager.requireProviderProcess({
processKey: "fake",
providerId: "fake",
}).child.pid ?? 0;
cleanupPids.push(providerPid);
expect(
await waitFor(() => stderr.join("").includes("grandchild=")),
).toBe(true);
const grandchildPid = Number(
/grandchild=(\d+)/.exec(stderr.join(""))?.[1] ?? 0,
);
cleanupPids.push(grandchildPid);
expect(isAlive(grandchildPid)).toBe(true);
if (process.platform === "linux") {
expect(readlinkSync(`/proc/${grandchildPid}/cwd`)).toBe(workspacePath);
}
// This is exactly what RuntimeManager.destroyEnvironment does before
// `workspace.destroy()` removes the directory.
await manager.shutdown();
expect(await waitFor(() => !isAlive(providerPid))).toBe(true);
// The workspace is now removed by destroyEnvironment; the grandchild keeps
// running with a cwd of "<workspace> (deleted)".
rmSync(workspacePath, { recursive: true, force: true });
const grandchildCwd =
process.platform === "linux" && isAlive(grandchildPid)
? readlinkSync(`/proc/${grandchildPid}/cwd`)
: "(process gone)";
console.log(
`provider pid ${providerPid} alive=${isAlive(providerPid)}; ` +
`grandchild pid ${grandchildPid} alive=${isAlive(grandchildPid)} cwd=${grandchildCwd}`,
);
expect(isAlive(grandchildPid)).toBe(false);
});
});
Root cause
Environment destroy is a daemon RPC (environment.destroy) dispatched by the server once the last live thread is gone (environment-cleanup-internal.ts#L263-L283). The daemon handler closes terminals then destroys the runtime entry (command-dispatch.ts#L551-L573):
// apps/host-daemon/src/runtime-manager.ts#L1117-L1131
async destroyEnvironment(environmentId: string): Promise<void> {
…
this.entries.delete(environmentId);
await this.stopWatchingStatus(entry);
await entry.runtime.shutdown(); // ← only signals direct provider child pids
await entry.workspace.destroy(); // ← git worktree remove --force
await this.cleanupUnusedInjectedSkillStagingDirs([]);
}
runtime-manager.ts#L1117-L1131. runtime.shutdown() ends in RuntimeProviderProcessManager.shutdown(), which does child.kill("SIGTERM") and, after 5 s, child.kill("SIGKILL") on the direct child only (runtime-provider-process.ts#L432-L466). The provider child is spawned without detached, i.e. in the daemon's own process group (#L378-L384), so there is no group to signal even if the code wanted to. Terminals are closed with pty.kill() = SIGHUP to the shell pid (terminal-manager.ts#L209-L213, #L766-L830); an interactive bash forwards SIGHUP to its jobs, but nohup/disown/setsid children ignore or never see it. Finally workspace.destroy() for a managed worktree is removeWorktree({force:true}) (provision.ts#L707-L730) which happily deletes a directory that live processes still have as cwd.
Why the symptom follows: anything an agent or terminal user launches "in the background" (dev servers, watchers, nohup jobs; on the claude-code side its Bash tool children even run in their own process group, as my PR-branch run showed) is disconnected from the only pids bb signals. After the bridge and shell exit those processes reparent to pid 1, keep their cwd inode, and consume RAM/CPU indefinitely. Two further paths make even the provider CLI itself leak: (1) if the bridge does not exit within 5 s it is SIGKILLed and cannot tear down its own children (claude, MCP servers) — matches the "claude alive 12h" report; (2) after a daemon restart, re-attached environments (reconnectManaged) have no handle to previously spawned provider processes at all, so a later destroy signals nothing.
Deeper issue: bb has no notion of process ownership per environment; it relies on the parent/child relationship of the specific pids it spawned. Related open issues #1363 (provider process lease ownership) and #1604 (idle providers never reclaimed for non-Codex providers) are the same gap seen from other angles.
Proposed fix (first principles)
Confident. Two layers, both host-daemon side (host-local primitive, correct per AGENTS.md; no wire change, no protocol bump):
- Own the tree structurally. Spawn provider CLIs, setup scripts and command terminals as process-group leaders (
detached: trueon POSIX, no-op on Windows) and signal the group (process.kill(-pid, sig)) on shutdown, with the escalation timer keyed on "any group member alive" rather than "leader exited". Preferably SIGTERM the leader first (so the bridge's gracefulonSigtermpath still drives the CLI down cleanly) and group-signal only at the escalation step. - Sweep on destroy. Right before
workspace.destroy()for a managed workspace, list processes whose cwd is at/under the workspace path (Linux/proc/*/cwd, macOSlsof -d cwd), SIGTERM, wait a short grace, rescan, SIGKILL; log the reaped pids. This catches new-session processes and anything spawned by a previous daemon instance. Consider also running the sweep for(deleted)cwds under the worktrees root on daemon start, so machines that already leaked get cleaned up once.
What could go wrong: the cwd sweep kills processes bb did not start (a user's own shell or editor terminal cd'd into the worktree) — acceptable for a directory that is about to be deleted, but it should be documented and logged; processes that chdir away (proper daemons) still escape and would need per-environment process tracking (cgroups/jobs) to catch. That is exactly the shape of PR #1696.
PR review — #1696 "Kill processes left in a destroyed worktree environment"
Head b3f6d2e7d (2 commits; commit 2 addressed the SlopCop review). Files: packages/process-utils/src/index.ts (+ new supportsProcessGroups, killProcessGroup, isProcessGroupAlive, listProcessesWithCwdUnder, killProcessesWithCwdUnder), packages/agent-runtime/src/runtime-provider-process.ts (spawn detached, group signals, keep SIGKILL timer while group alive), packages/host-workspace/src/provisioning.ts (setup script uses shared helper), apps/host-daemon/src/terminals/terminal-manager.ts (pty kill → group), apps/host-daemon/src/runtime-manager.ts (killManagedWorkspaceProcesses before workspace.destroy()), tests in process-utils/test/process-tree.test.ts and runtime-manager.test.ts.
Does it address the root cause? Yes — it is the two-layer fix above and stays on the daemon side; no server/daemon wire change so no HOST_DAEMON_PROTOCOL_VERSION bump is needed (verified: no contract files touched).
Tests I ran
pnpm exec turbo run test --filter=@bb/process-utils --filter=@bb/agent-runtime --filter=@bb/host-workspace --filter=@bb/host-daemon --force: process-utils 18/18, host-workspace 213/213, host-daemon 563/563, agent-runtime 953/953 passed (log).- My unit repro passes on the PR branch (fails on main).
- Live retest on the PR branch (same repro A + C, plus a process bb did not start rooted in the worktree via 1647-user-shell.sh):
$ /tmp/1647-procs.sh …/worktrees/env_ywfyfbzxqj/1647-qa # before delete (PR build)
1632470 ppid=1630562 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa /home/sawyer/.nvm/versions/node/v24.18.0/bin/node --conditions=source --import file:///home/sawyer/projects/bb
1632496 ppid=1632470 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json --thinking ada
1634653 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa sleep 3000
1636730 ppid=1630562 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa /bin/bash
1641293 ppid=1 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa sleep 4000
1644289 ppid=1636730 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa python3 -m http.server 18647
$ curl -s -X DELETE http://localhost:19440/api/v1/threads/thr_r7jf625hqm … ; sleep 3; /tmp/1647-procs.sh …
Tue Aug 18 05:27:13 AM UTC 2026
{"ok":true}
--- t+3s env status: "status": "destroyed",
--- t+6s env status: "status": "destroyed",
--- t+9s env status: "status": "destroyed",
--- t+12s env status: "status": "destroyed",
--- t+15s env status: "status": "destroyed",
--- t+18s env status: "status": "destroyed",
--- t+21s env status: "status": "destroyed",
--- t+24s env status: "status": "destroyed",
PID CMD
# daemon log:
{"level":20,"time":1787029272947,"component":"host-daemon","serverUrl":"http://127.0.0.1:19440","commandType":"environment.destroy","handlerMs":5017.7,"ok":true,"msg":"Online host RPC"}
{"level":20,"time":1787030436801,"component":"host-daemon","serverUrl":"http://127.0.0.1:19440","commandType":"environment.destroy","handlerMs":4049.4,"ok":true,"msg":"Online host RPC"}
{"level":40,"time":1787030833845,"component":"host-daemon","serverUrl":"http://127.0.0.1:19440","environmentId":"env_ywfyfbzxqj","workspacePath":"/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_ywfyfbzxqj/1647-qa","pids":[1634653,1641293,1644289],"msg":"Killed processes still running in a destroyed environment"}
All three background processes (agent's sleep 3000, terminal's http.server, and my non-bb sleep 4000) are gone; the environment reads destroyed 3 s after delete. I also verified that pnpm dev:stop on the PR branch leaves no orphaned bridge/claude behind even though the bridge is now its own session leader (stdin close still drives the bridge's graceful exit). Files: before, after, daemon log.
Findings
| Sev | Where | Finding |
|---|---|---|
| medium | packages/process-utils/src/index.ts listProcessesWithCwdUnder / apps/host-daemon/src/runtime-manager.ts killManagedWorkspaceProcesses | The sweep is ownership-agnostic: it SIGTERM/SIGKILLs any user process whose cwd is inside the workspace, including ones bb never started (demonstrated: pid 1641293 above). A user's own terminal, an editor's integrated shell, or a debugger session sitting in the worktree gets killed when the environment is deleted. Probably acceptable since the directory is being removed, but it is a silent behavior change and should be stated in the PR/docs and the log line should be info-visible in the UI's environment lifecycle, not only daemon warn. Alternative: only SIGTERM (no SIGKILL) processes that are not descendants of pids bb spawned. |
| medium | packages/agent-runtime/src/runtime-provider-process.ts shutdown() / terminateProviderProcess() | The first signal is now SIGTERM to the whole group, so claude/codex app-server receive SIGTERM at the same instant as the bridge, racing the bridge's onSigterm → closeThreadSessionsGracefully path (which expects to drive the CLI down itself). Nothing broke in my runs, but a safer ordering is: SIGTERM the leader, then group-SIGTERM/SIGKILL on the existing 5 s escalation. Same helper, one line each. |
| low | runtime-provider-process.ts exit handlers | When the leader exits while a group member is still alive, the promise now resolves only at the timer (5 s / timeoutMs+1000) even if the member dies 50 ms later. Poll isProcessGroupAlive at a short interval instead of waiting for the hard timer, otherwise every idle-reap/provider swap that has any straggler costs 5–6 s. |
| low | listLsofProcessCwds (macOS) | Runs lsof over every host process up to 2× per round, up to 5 rounds per destroy, and once more per concurrent destroy. On a loaded Mac lsof can take seconds. Consider one scan restricted with -u $(id -u) and a shorter round limit; also lsof missing/erroring is silently treated as "no processes" — log it. |
| low | terminal-manager.ts pty kill | Group-signalling the pty group is effectively a no-op for interactive shells (job control puts every job in its own pgrp; bash already re-sends SIGHUP to jobs) and only helps --command terminals. Fine to keep, but the comment overstates it ("background jobs die with the shell") — they die only via the cwd sweep, and only on environment destroy, not on plain terminal close. |
| low | PR description | Stale: says "ACP agents (agent-connection.ts) … spawn as process-group leaders" but commit 2 reverted that file (they now stay in the ACP bridge's group, which is the right call). Update the description. |
| low | Residual gap (not a defect of the PR) | Processes that chdir out of the workspace (daemonized servers) and processes leaked by environments destroyed while the daemon was offline (destroy.lost) or before this change are not covered. A one-time startup sweep of (deleted) cwds under the daemon's worktrees root would clean up existing machines like the reporter's. |
Cast/unknown smuggling: none. Security: the sweep root is always a bb-controlled managed path (worktree under the data dir, or a validated personal-workspace path), the symlink guard prevents redirecting the sweep, and the current daemon pid is excluded; PID-reuse window between scan and SIGTERM is tiny and was narrowed by the rescan-before-SIGKILL change.
Verdict: REQUEST CHANGES (minor) — it fixes the root cause and I could not break it, but I want the leader-first signal ordering and an explicit call-out of the ownership-agnostic sweep before merge; the rest are nits.
Related issues
- #1769 — duplicate with the macOS numbers (closed).
- #1660 — 77 GB RSS host; orphaned dev processes in deleted worktrees compound it.
- #1604 — idle agent processes never reclaimed for non-Codex providers (same "no ownership" gap; the PR's group kill also affects those reaps).
- #1363 — provider processes need one host-daemon lease owner.
Appendix
Helper scripts
#!/bin/bash
# List every process whose cwd is at or under $1 (Linux /proc). Prints pid, ppid, cwd, cmdline.
dir="$1"
for p in /proc/[0-9]*; do
pid=${p#/proc/}
cwd=$(readlink "$p/cwd" 2>/dev/null) || continue
case "$cwd" in
"$dir"|"$dir/"*|"$dir (deleted)"|"$dir/"*" (deleted)")
ppid=$(awk '/^PPid:/{print $2}' "$p/status" 2>/dev/null)
cmd=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null | cut -c1-110)
printf '%s\tppid=%s\t%s\t%s\n' "$pid" "$ppid" "$cwd" "$cmd";;
esac
done
#!/bin/bash # Simulates a process bb did NOT start (e.g. the user's own terminal cd'd into # the worktree, or an editor process) whose cwd is the worktree directory. cd "$1" || exit 1 setsid sleep 4000 >/dev/null 2>&1 < /dev/null & echo "user-shell-sleep pid $!"
Mid-turn control (B)
ENV=env_xy4dyt63ue
WS=/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_xy4dyt63ue/1647-qa
1520263 ppid=1472667 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_xy4dyt63ue/1647-qa /home/sawyer/.nvm/versions/node/v24.18.0/bin/node --conditions=source --import file:///home/sawyer/projects/bb
1520286 ppid=1520263 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_xy4dyt63ue/1647-qa /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json --thinking ada
1521383 ppid=1520286 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_xy4dyt63ue/1647-qa /bin/bash -c source /home/sawyer/.claude/shell-snapshots/snapshot-bash-1787030410931-uknrz8.sh 2>/dev/null ||
1521415 ppid=1521383 /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-9-3d811fb8a183/worktrees/env_xy4dyt63ue/1647-qa sleep 90
Tue Aug 18 05:20:32 AM UTC 2026
{"ok":true}
--- t+3s env status: "status": "destroying",
--- t+6s env status: "status": "destroyed",
--- t+9s env status: "status": "destroyed",
--- t+12s env status: "status": "destroyed",
--- t+15s env status: "status": "destroyed",
--- t+18s env status: "status": "destroyed",
--- t+21s env status: "status": "destroyed",
--- t+24s env status: "status": "destroyed",
--- t+27s env status: "status": "destroyed",
--- t+30s env status: "status": "destroyed",
Commands run (chronological)
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
scripts/bb-dev-app current # app :11440 server :19440 daemon :27440
git init /tmp/1647-qa … ; curl -X POST $BB_SERVER_URL/api/v1/projects … # proj_ymakza7kkq
/tmp/1647-bb.sh thread spawn --new-environment worktree --provider claude-code … (A, B, C)
/tmp/1647-procs.sh <worktree path>
curl -X DELETE http://localhost:19440/api/v1/threads/<id> -d '{"childThreadsConfirmed":true}'
/tmp/1647-bb.sh environment show <env> --json
/tmp/1647-bb.sh terminal create --environment <env> ; terminal send … --enter
cd packages/agent-runtime && pnpm exec vitest run src/repro-1647-orphaned-grandchildren.test.ts # FAILS on main
git fetch origin pull/1696/head ; git checkout b3f6d2e7d ; pnpm install ; turbo build (4 pkgs)
pnpm exec turbo run test --filter=@bb/process-utils --filter=@bb/agent-runtime --filter=@bb/host-workspace --filter=@bb/host-daemon --force
scripts/bb-dev-app current (PR build) ; repeat A + C + user-shell ; delete ; inspect daemon log
pnpm dev:stop ; git checkout 16ceb3a54 ; pnpm install ; re-run unit repro on main (fails)
Logs: install, build, dev app (main), dev app (PR), PR diff, PR tests.