Background/bridge sessions leak .claude/worktrees worktrees: no cleanup or stale-lock reaping on the daemon kill/settle path

Resolved 💬 2 comments Opened Jul 10, 2026 by SureneruS Closed Jul 14, 2026

Environment

  • Claude Code 2.1.203–2.1.206 (observed across daemon self-upgrades), macOS (Darwin 25.5.0)
  • Sessions involved: claude.ai bridge sessions (bridgeSessionId: cse_*) hosted by claude daemon, background jobs, and Agent-tool subagents with isolation: worktree

Summary

The only built-in cleanup for a worktree a session is parked in is the interactive session-exit keep/remove prompt. Non-interactive sessions (bridge sessions, background jobs, subagent orchestrators) can never reach that prompt. When such a session is closed from the job list, the daemon logs bg settled <id> (killed) and performs no worktree action whatsoever (the daemon log contains zero worktree events). The worktree directory, its branch, and — for EnterWorktree(name) worktrees — a git worktree lock naming a now-dead pid are all orphaned.

On one machine, roughly half of all worktrees ever created had leaked (12 leaked vs ~12 cleaned; the cleaned ones were removed only because the agent happened to run git worktree remove itself, not by any harness guarantee). One killed parallel-agent run leaked 9 subagent-isolation worktrees at once, all locked by the same dead orchestrator pid.

Reproduction

  1. Start a background/bridge session in a git repo; have it call EnterWorktree({name: "x"}) and do some work (merge or not — doesn't matter).
  2. Close the session from the job list without the agent calling ExitWorktree.
  3. Observe: daemon.log shows bg settled <id> (killed); the worktree, branch, and .git/worktrees/x/locked (naming the dead worker pid) remain indefinitely.

Why the existing safety nets don't catch it

  1. cleanupPeriodDays sweep skips worktrees with untracked files — but real worktrees almost always have node_modules/, __pycache__/, or scratch files — and it cannot remove a locked worktree. In practice the sweep can never reap these leaks.
  2. No stale-lock reaping: the lock names a pre-spawned spare/worker pid + start time. Nothing ever revalidates it after the process dies (docs explicitly leave kill/crash behavior undefined). A crash or kill converts the lock into a permanent one.
  3. Job-record destruction: closing a job purges its state.json (which held worktreePath), and bridge sessions never have a jobs record at all — so no metadata survives to drive retroactive cleanup.
  4. EnterWorktree(path) (enteredExisting: true) is contractually exempt from all auto-removal, so a session that creates a worktree with raw git worktree add (e.g. because EnterWorktree fails when the session was launched from a non-repo cwd) opts out of lifecycle management entirely — even a graceful exit leaves it behind.

Suggested fixes (in root-cause order)

  1. On the daemon settle/kill path, when the dying session's cwd/state is inside a .claude/worktrees/ worktree: release the lock, and remove the worktree if its branch is merged and it has no meaningful uncommitted work (or at minimum unlock + record it for the next sweep).
  2. Reap stale locks whose recorded pid+start-time no longer matches a live process — the daemon already has worker adopt/respawn machinery at upgrade takeover, which is a natural place for this.
  3. Let the cleanupPeriodDays sweep reap merged worktrees whose only dirt is ignorable cruft and whose lock pid is dead.
  4. When a background session is launched from a directory that is not a git repo, allow EnterWorktree(name) to target a repo nested under the cwd instead of failing — the current failure pushes agents into the unmanaged raw-git worktree add path that can never be auto-cleaned.

Evidence available on request

  • daemon.log excerpts (bg settled … (killed) with no worktree events; spare-pool spawn lines matching lock pids to the second)
  • Session transcripts showing EnterWorktree with zero ExitWorktree calls
  • Census: 12 leaked worktrees across 3 repos, incl. 9 from one killed parallel-agent run (single dead orchestrator pid on all locks)

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗