Skip to content

Kill processes left in a destroyed worktree environment - #1696

Merged
SawyerHood merged 3 commits into
mainfrom
bb/investigate-1647-thr_7vuiqfqzqf
Aug 18, 2026
Merged

Kill processes left in a destroyed worktree environment#1696
SawyerHood merged 3 commits into
mainfrom
bb/investigate-1647-thr_7vuiqfqzqf

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1647

Independent report: https://get-bb.github.io/reports/issues/1647.html (section "PR review").

Problem

environment.destroy closed terminals with pty.kill() and stopped provider CLIs with child.kill("SIGTERM"). Both signal only the direct child pid. Grandchildren (dev servers from an agent's Bash tool, MCP servers, nohup/disowned jobs) survived git worktree remove with a cwd in the deleted directory. Reproduction: SIGTERM to a sh -c 'sleep 300 & wait' child, then remove its cwd; /proc/<pid>/cwd shows <path> (deleted) for the sleep.

Change

  • @bb/process-utils: add supportsProcessGroups, killProcessGroup, isProcessGroupAlive, stopProcessGroupLeaderFirst, listProcessesWithCwdUnder, and killProcessesWithCwdUnder (Linux /proc/*/cwd; macOS lsof -d cwd; no-op on Windows).
  • Provider CLIs (runtime-provider-process.ts) and setup scripts (provisioning.ts) spawn as process-group leaders. The setup script already did this; the helper is now shared. ACP agents (agent-connection.ts) are unchanged: they stay in the ACP bridge's process group.
  • Provider shutdown is leader-first: SIGTERM goes to the bridge alone so it can close its CLI sessions gracefully. If the leader exits while group members are still alive, the group gets SIGTERM and is polled; if anything is still alive at the timeout, the group gets SIGKILL.
  • Terminal close/force-close/shutdown signal the pty process group.
  • RuntimeManager.destroyEnvironment sweeps and kills every process still rooted in a managed workspace (SIGTERM, 2 s grace, rescan, SIGKILL) before it removes the directory, and logs the reaped pids. forgetEnvironment and idle eviction do not sweep because the workspace stays on disk.

The cwd sweep is ownership-agnostic

The sweep kills any process of the current user whose cwd is inside the destroyed managed workspace, including processes bb never started: a shell you cd'd into the worktree, an editor terminal, a debugger. The directory is about to be removed, so those processes lose their cwd anyway. This is deliberate and documented in the code comment at the sweep, in docs/worktrees.md, and in the bb guide environments chapter. Personal (non-managed) workspaces are never swept.

No server/daemon wire change, so no protocol version bump.

Tests

  • packages/process-utils/test/process-tree.test.ts: group kill reaps a grandchild; the cwd sweep finds and kills a detached session leader and its child, rescans for respawned processes, ignores the current process, sibling directories, and symlinked roots; leader-first stop lets the leader shut its own child down (a group-wide SIGTERM would reach the child first); a member that ignores SIGTERM and outlives the leader is reaped by the group escalation.
  • apps/host-daemon/src/runtime-manager.test.ts: destroyEnvironment kills a detached process rooted in a managed workspace.
  • Ran turbo run typecheck test for @bb/process-utils, @bb/agent-runtime, @bb/host-daemon, @bb/templates: all pass.

AGENT GENERATED: by Claude Opus 5

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

I am the Slop Cop. I started my review of this process-cleanup change.

I will check security, code quality, performance, tests, and architectural duplication. I will post one final review after these checks finish.

Comment thread packages/process-utils/src/index.ts Outdated
for (const target of signalled) {
if (isProcessAlive(target.pid)) {
try {
process.kill(target.pid, "SIGKILL");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The final SIGKILL can target an unrelated process after PID reuse.

The code waits two seconds and then checks only PID existence. The operating system can reuse an exited target's PID during that wait.

Save the process start identity. Verify that identity and the workspace path before each signal. Use a pidfd on Linux when possible.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3f6d2e. The sweep now rescans before each signal and only SIGKILLs pids that a fresh scan still shows rooted in the workspace. A reused pid with a cwd elsewhere no longer receives a signal.

Comment thread packages/process-utils/src/index.ts Outdated
}
let directory = resolve(args.directory);
try {
directory = await realpath(directory);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — A changed workspace symlink can redirect this process sweep.

realpath() follows the workspace root at destroy time. Worktree code can replace that root with a symlink before destruction.

BB can then signal unrelated processes under the symlink target. Store the canonical device and inode after provision. Reject identity changes before the scan.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mitigated in b3f6d2e. The sweep resolves the parent path and skips the sweep when the root itself is a symlink. Storing device/inode at provision is more than this change needs: BB owns the worktree root, and a process that can swap it already runs on the host.

): AcpAgentConnection {
const child: ChildProcess = spawn(options.command, options.args, {
cwd: options.cwd,
detached: supportsProcessGroups(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The ACP agent now leaves the bridge process group.

Runtime shutdown signals the outer bridge group. The bridge handles a closed input stream, but it has no SIGTERM handler.

The ACP agent group can therefore survive daemon shutdown or idle eviction. Add signal handlers that stop all sessions and escalate when necessary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3f6d2e. I reverted the ACP agent to stay in the bridge process group. The bridge itself is now a group leader, so the runtime's group signal reaches the bridge, the agent, and their children.

Comment thread packages/process-utils/src/index.ts Outdated
export async function killProcessesWithCwdUnder(
args: KillProcessesWithCwdUnderArgs,
): Promise<ProcessWithCwd[]> {
const targets = await listProcessesWithCwdUnder({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — One current-directory snapshot does not stop processes that appear during shutdown.

I reproduced this race. A target handled SIGTERM, created a new-session child, and exited. This helper returned while the new child remained alive.

Repeat the scan until no matching process remains. An owned process-group or operating-system job boundary would give stronger control.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3f6d2e. killProcessesWithCwdUnder now loops: scan, SIGTERM, wait, rescan, SIGKILL, and repeat until a scan is empty (bounded to 5 rounds). A test covers a target that spawns a setsid child on SIGTERM.

new Promise<void>((resolve) => {
const timer = setTimeout(() => {
providerProcess.child.kill("SIGKILL");
killProcessGroup({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The group escalation stops when only the leader exits.

This exit handler clears the SIGKILL timer without checking the process group. A direct probe confirmed that a group member can remain alive.

Keep the timer until kill(-pgid, 0) reports no group. Send SIGKILL to the group after the grace period.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3f6d2e. On leader exit both shutdown paths check kill(-pgid, 0) and keep the SIGKILL timer while group members remain.

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

Plain-English summary: This PR tries to stop commands, terminals, and agents that remain after BB deletes a worktree.

The process-group helper is a useful shared abstraction. The runtime manager also keeps environment policy in the correct host-daemon layer.

I found five important correctness and safety defects:

  1. A replaced workspace symlink can redirect cleanup toward unrelated processes.
  2. A process can create a new child after the single current-directory scan. I reproduced this survivor race.
  3. PID reuse during the grace period can send SIGKILL to an unrelated process.
  4. ACP agents now enter a separate group that outer provider shutdown does not reach.
  5. Provider escalation stops when the group leader exits, even if other group members remain.

The macOS implementation also scans every host process with lsof for each destroyed environment. Concurrent destroys multiply that cost.

The focused Turbo typechecks passed for all four affected packages. The focused Turbo test run failed in the new process-tree test.

That test reached its five-second timeout under the normal parallel package run. A separate run passed, which shows a load-sensitive test.

The source dev app loaded in Chromium. I created and deleted a managed environment through the local lifecycle path. The worktree was removed.

The direct race probe still left a new-session child alive after cleanup. The inline comments contain the specific fixes.

Architecture note: Keep process primitives in @bb/process-utils. Add one verified group-termination operation and reuse the existing process-identity checks.

Do not use a current directory as the only ownership boundary. Use tracked process groups or operating-system jobs when the platform supports them.

SawyerHood and others added 3 commits August 18, 2026 17:59
Environment teardown only signalled the shell and provider CLI pids. Their
children (dev servers, MCP servers, background jobs) survived with a cwd in
the removed directory. Spawn provider CLIs, ACP agents, and setup scripts as
process-group leaders and signal the group; signal the pty process group on
terminal close; and sweep every process still rooted in a managed workspace
before its directory is removed.

Fixes #1647

Co-Authored-By: Claude <noreply@anthropic.com>
…oup escalation

- killProcessesWithCwdUnder rescans before each signal and repeats until a
  scan is empty, so reused pids and processes that appear during shutdown
  never receive a stale signal.
- The sweep skips a workspace root that is itself a symlink.
- Provider shutdown keeps the SIGKILL timer while group members outlive the
  leader.
- ACP agents stay in the bridge process group so the bridge group kill
  reaches them.

Co-Authored-By: Claude <noreply@anthropic.com>
… sweep

- Add stopProcessGroupLeaderFirst to @bb/process-utils: SIGTERM the group
  leader alone so the provider bridge can drive its CLI down gracefully;
  SIGTERM the group only after the leader exits with members alive, poll
  the group, and SIGKILL the group on timeout. Both provider shutdown paths
  use it.
- Real-process tests for leader-first ordering and group escalation.
- Call out that the destroy-time cwd sweep kills any user process rooted in
  the workspace, in code comments, docs/worktrees.md, and the environments
  guide chapter.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/investigate-1647-thr_7vuiqfqzqf branch from b3f6d2e to 0aaef37 Compare August 18, 2026 18:06
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Applied the two changes from the independent review (https://get-bb.github.io/reports/issues/1647.html, section "PR review"), rebased on main:

  1. Leader-first signal ordering. New stopProcessGroupLeaderFirst in @bb/process-utils, used by both provider shutdown paths in runtime-provider-process.ts. SIGTERM goes to the bridge alone so it can drive its CLI down gracefully. When the leader exits and group members are still alive, the group gets SIGTERM and is polled every 100 ms; anything still alive at the timeout gets group SIGKILL. This also removes the 5–6 s wait when a straggler dies shortly after the leader (the "low" finding). Tests: process-tree.test.ts proves the child sees only the leader-driven signal (a group-wide SIGTERM would log child-term first), and that a member which ignores SIGTERM is reaped by the escalation.
  2. Ownership call-out. The cwd sweep kills any user process rooted in the destroyed managed workspace, including ones bb never started. Documented in the comment at killManagedWorkspaceProcesses, the killProcessesWithCwdUnder doc, docs/worktrees.md, and the bb guide environments chapter. PR description refreshed (ACP agents stay in the bridge's group; no protocol bump).

AGENT GENERATED: by Claude Opus 5

@SawyerHood
SawyerHood merged commit 7c2be81 into main Aug 18, 2026
10 checks passed
@SawyerHood
SawyerHood deleted the bb/investigate-1647-thr_7vuiqfqzqf branch August 18, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deleting a worktree environment leaves its processes running

1 participant