[BUG] Worktree recycling destroys live sibling sessions' worktrees — including locked ones and uncommitted work

Status Open
Reported on v2.1.207
Maintainer reply None cached
Activity 3 comments · opened Jul 13, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Summary

Under heavy multi-session use of one repository (many concurrent Claude Code sessions, peak ~18 registered worktrees), Claude Code's worktree recycling repeatedly deletes worktrees that belong to other live sessions. It does not respect git worktree lock, and recycling a worktree that contains uncommitted/untracked files destroys them (recycle = delete + recreate fresh checkout). One session's worktree was destroyed five times in a single session; across sessions the same day we permanently lost uncommitted work once (7 dirty files) and nearly lost a 23-file feature implementation.

Observed timeline (one session, 2026-07-13)

  1. Session starts in registered worktree .claude/worktrees/<name> (clean, tip == main — it was a read-only analysis session).
  2. Worktree deleted out from under the session mid-command: "Working directory ... was deleted; shell cwd recovered to ...". No harness notice at this point.
  3. Session recreates the worktree manually (git worktree add) and locks it: git worktree lock --reason "ACTIVE Claude session — do not prune" <path>. Deleted again ~3 minutes later. Metadata under .git/worktrees/ was cleanly removed — the lock was not honored (plain git worktree remove, even with a single --force, refuses locked trees; whatever removed it went past that).
  4. Session creates a worktree outside the repo at /private/tmp/<name> (same branch) and edits files there. Deleted minutes later with uncommitted changes in it.
  5. Harness then emits, twice in succession: "The git worktree at ... was recycled. This session now operates on a fresh worktree at ..."
  6. Finally: "The git worktree at ... was deleted. This session now operates on the origin repository at ..." — i.e. after losing the race repeatedly, the session is dropped into the shared main checkout, the highest-contention place possible, where its writes collide with every other session.

Corroborating losses (same repo, same day, other sessions)

  • A session's worktree holding 7 dirty uncommitted files (feature WIP) was swept mid-session; the files were permanently lost.
  • A worktree holding a 23-file uncommitted feature implementation was deleted; the building session's own commit message recorded the work as "lost to repeated worktree recycling before commit". (Recovered only because that session had copied the files into the main checkout minutes before.)
  • A third session reported: "my worktree got deleted mid-command by a parallel session (the 18-worktree contention)". Every session perceives a hostile "parallel session"; the recycle notices in step 5 indicate the deletions come from Claude Code's own worktree lifecycle.

Why this seems like a bug

Docs describe worktrees as "auto-cleaned if unchanged" — but a live session's in-use worktree qualifies merely because its tree is clean and its branch tip equals main, which is also the signature of a freshly started or read-only session. And dirty worktrees were deleted too.

Suggested fixes

  1. Ownership/liveness check before recycle: only remove worktrees created by the current session, or lock worktrees at creation with --reason "pid <N> session <id>" and probe liveness before removal (this also makes liveness visible to external tooling).
  2. Honor git worktree lock unconditionally — never double-force a locked tree.
  3. Never delete dirty: if git status --porcelain is non-empty, refuse or move the tree aside (e.g. rename to <name>.orphaned-<ts>) instead of deleting.
  4. Don't fall back to the origin repository after repeated recycles — that converts an isolation mechanism into a shared-checkout collision.
  5. Log which process/heuristic triggered each recycle in the notice, so multi-session users can diagnose.

What Should Happen?

  • A worktree registered to a live session is never deleted by another session's harness.
  • git worktree lock (git's native "in use" claim) is honored.
  • A worktree containing uncommitted or untracked files is never deleted silently; recycling preserves (or refuses on) dirty state.
  • A session is never silently relocated into the shared main checkout while sibling sessions are active.

Error Messages/Logs

Bash tool, mid-session (no prior notice):
  Working directory "/Users/<user>/dev/<repo>/.claude/worktrees/<name>" was deleted; shell cwd recovered to "/Users/<user>". Re-issue your command (it will run from the recovered directory).

Harness system reminders, later in the same session (back to back):
  The git worktree at /Users/<user>/dev/<repo>/.claude/worktrees/<name-1> was recycled. This session now operates on a fresh worktree at /Users/<user>/dev/<repo>/.claude/worktrees/<name-2>, checked out to the same branch (<branch>). Absolute paths from earlier in the conversation that reference the old worktree no longer exist.

  The git worktree at /Users/<user>/dev/<repo>/.claude/worktrees/<name-2> was recycled. This session now operates on a fresh worktree at /Users/<user>/dev/<repo>/.claude/worktrees/<name-3> ...

  The git worktree at /Users/<user>/dev/<repo>/.claude/worktrees/<name-3> was deleted. This session now operates on the origin repository at /Users/<user>/dev/<repo>. File paths from earlier in the conversation that reference the worktree no longer exist.

Environment: Claude Code 2.1.207 (desktop app), macOS 26.6, git 2.50.1 (Apple Git-155). Many concurrent sessions against one repo; peak ~18 registered worktrees.

Steps to Reproduce

  1. Pick one repository. Start 6–10 concurrent Claude Code sessions against it, each isolated in a .claude/worktrees/<name> worktree (the default worktree isolation).
  2. Keep one session read-only (analysis/review work): its tree stays clean and its branch tip stays == main.
  3. Watch git worktree list while other sessions start and stop.

Observed results:

  1. The read-only session's worktree is removed by sibling activity within minutes ("Working directory ... was deleted" on its next Bash call, later "The git worktree at ... was recycled" notices).
  2. Repeat with git worktree lock --reason "in use" <path> applied — the worktree is still removed (metadata cleanly deleted, so it is a real remove/prune, not an rm).
  3. Scatter untracked files into a candidate worktree first — they are destroyed on recycle.
  4. After several recycle rounds the session is relocated into the origin repository (shared main checkout).

Related issues (this report is distinct from both):

  • #74386 covers a user-directed discard with no liveness signal; this report shows the AUTOMATIC recycler doing the same unprompted, and bypassing git worktree lock — a liveness signal that does exist.
  • #74782 (closed) covers the mid-session teardown + silent fall-through to the parent repo's .git; still reproducible on 2.1.207, and the "operates on the origin repository" fallback here recreates exactly the shared-checkout hazard that issue describes.

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.207 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Other

Additional Information

Sessions run in the Claude Code desktop app (macOS) — hence Terminal/Shell = Other. Cursor IDE worktrees under ~/.cursor/worktrees/<repo>/ also existed in the same repo but were NOT the actor: no repo script or IDE process was running at the deletion times, and the harness's own "was recycled" notices account for the removals.

User-side mitigations we've adopted meanwhile, which may be useful signal for the fix design: worktree-locking at creation with a PID reason, an age-floor + lock-respect patch to our repo's own cleanup script, and falling back to plain git clone (own .git, invisible to git worktree list) as the only isolation that survives — which defeats the purpose of the built-in worktree isolation.

View original on GitHub ↗

3 Comments

szhygulin · 1 month ago

Independent reproduction on 2.1.209 — and it is not confined to heavy multi-session use

Corroborating this on a different surface, with a much smaller worktree population than the ~18 in the report, plus five controlled probes that rule out three plausible-but-wrong triggers. Environment: Claude Code 2.1.209, macOS arm64, model claude-opus-4-8.

What happened

Two isolation: "worktree" Agent-tool subagents (not sessions in manually-registered worktrees — a different entry point to the same recycler) had their worktrees deleted mid-run, at ~225s and ~227s elapsed, while both were alive and mid-task. Every subsequent tool call failed:

File does not exist. Note: your current working directory is
/…/.claude/worktrees/agent-<id>.

Repo had only 4 registered worktrees at the time (main + one analysis worktree + the two agent worktrees). So the trigger does not require the ~18-worktree, heavy-concurrency regime described above — two harness-managed agent worktrees was enough.

Corroborating the two load-bearing claims in this report

1. The lock is not honored — confirmed independently. The harness does lock these worktrees while the agent runs; git worktree list shows it plainly for a live agent:

/…/.claude/worktrees/agent-a3e0e5ecab3080397  f4b7d40 [worktree-agent-a3e0e5ecab3080397] locked

The two that died were locked by the same mechanism, and were removed anyway.

2. Clean removal semantics, not an rm -rf. Directory and .git/worktrees/ admin entry both gone, branch retained, and a subsequent git worktree prune found nothing to prune. A stray rm -rf leaves a stale admin entry that prune then reports. Whatever removed them went through git's own removal path, past the lock.

3. Nothing logs it. ~/.claude/daemon.log contains no record of the removal — no "removed", no "recycled". The victim discovers the deletion only via ENOENT on its next tool call. Even a single log line naming the remover would collapse the diagnostic cost here from hours to seconds.

Ruling out #75609 for this instance

#75609 attributes worktree deletion to a failed self-update. Not our path: our update succeeded ~12 h before the incident, and there were no daemon worktree events.

{"timestamp":"2026-07-14T06:57:58Z","path":"native","outcome":"success",
 "status":"success","version_from":"2.1.208","version_to":"2.1.209","error_code":null}

So the recycler can destroy live locked worktrees without a failed update in the picture. Worth keeping the two issues distinct.

Negative results — five controlled probes, three triggers ruled out

I could not reproduce it deliberately, which is itself triage signal. Each probe was an isolation: "worktree" agent in the same repo/harness, polling test -d "$(pwd)" every 20–30s:

| hypothesis | probe | max elapsed | verdict |
|---|---|---|---|
| "auto-cleaned if unchanged" fires on a live agent (worktree stays clean during the long read-only phase) | control: empty git status throughout, then an edit | 402s | NOT the trigger — survived |
| Two concurrent worktree agents collide | probes A + B, launched in one message | 351s / 419s | NOT the trigger — both survived |
| Agent switches off the harness-assigned worktree-agent-<id> branch via git checkout -b (one victim had done exactly this) | probe C (210s, inconclusive) → probe D | 498s | NOT the trigger — survived, 2.2× past the death window |

Also excluded by direct inspection: neither victim removed its own worktree (audited every git/rm command in both transcripts — no git clean, no rm -rf, no git worktree remove/prune), and no hook or script in my setup touches worktrees.

So: not idle-unchanged reaping, not 2-way concurrency, not branch switching, not self-inflicted, not a failed update. The trigger remains unidentified — consistent with this report's "recycling" framing, but I could not force it.

Impact here

No uncommitted work was lost (both victims were still in their read-only phase, which is why I initially misread this as the "unchanged ⇒ reaped" behaviour). The cost was two killed agents and a long false-lead investigation. Others will be less lucky: this report already documents 7 dirty files destroyed, and the entry point I hit — the Agent tool's isolation: "worktree" — is the one most likely to be running unattended work nobody is watching.

Suggestions, in order of value

  1. Never recycle a worktree whose agent/session is live. Liveness is knowable from the harness's own agent registry — this need not rely on the git lock at all.
  2. Honor git worktree lock. The harness sets the lock itself; something in the same process ignores it. Whichever component removes these should refuse a locked tree, or state loudly why it is overriding.
  3. Log the removal (who, what, why) to daemon.log. Right now a destroyed worktree is indistinguishable from a mystery, and the victim's error message points at the symptom (cwd does not exist), which sends everyone hunting their own tooling first. I audited hooks, scripts, and my own agents' commands before suspecting the harness.
  4. Until fixed, the workaround that held for me: provision worktrees explicitly (git worktree add + pass cwd to the agent) instead of isolation: "worktree". Every explicitly-provisioned worktree in the same session survived; only the harness-managed ones died. Small sample, unknown mechanism — but the asymmetry was 100%.

Happy to supply the full probe timelines or the victim transcripts if useful.

kcarriedo · 1 month ago

The worktree recycler bypassing git worktree lock is the sharp edge here. The lock exists precisely so external actors can't remove a worktree that something considers in-use. When the recycler ignores it (and the corroborating report from szhygulin confirms this is reproducible on 2.1.209), the lock has no protective value in practice.

The most robust fix is probably not git-lock-based at all -- it is a harness-level liveness registry: a lightweight file under .claude/ (or a named pipe / UNIX socket) where each session records its own session ID, PID, and worktree path at start, and clears the record on clean exit. Before recycling any worktree, the recycler checks: is any live session record pointing at this path? If yes, skip it. The git lock is then a second-layer defense, not the primary one.

The explicitly provisioned worktree survives, harness-managed one dies asymmetry you documented in the probes is useful triage signal. It suggests the recycler's reap heuristic keys on worktrees it created itself (tracked in some internal registry) rather than all worktrees under .claude/worktrees/. If the harness only recycles worktrees it originally registered, a worktree added with git worktree add outside the harness would be invisible to the recycler -- which matches the survival pattern you observed.

A few local mitigations for anyone hitting this while waiting on a fix:

  1. Provision worktrees explicitly outside the harness: git worktree add .claude/worktrees/<name> -b <branch> before starting the session, then point the session at that path. Based on the probe results above, explicitly-provisioned worktrees are not targeted by the recycler.
  1. Use a pre-session hook to lock the worktree the session is starting in: git worktree lock --reason "pid-$$ claude-session-$(date +%s)" "$CLAUDE_WORKTREE_PATH". Locks set inside the session's process tree are still getting bypassed, but it creates an auditable record.
  1. Drop a sentinel file in the worktree at session start (a CLAUDE_SESSION_ACTIVE file with PID and timestamp). This does nothing to stop the recycler today, but gives you post-mortem evidence: if the file is missing after a recycle, the recycler removed it rather than the session cleaning up.

The "never fall back to the origin repository after repeated recycles" suggestion in the report is important. Landing an agent in the shared main checkout after it has lost its isolation is worse than a hard failure -- it converts a session isolation bug into a data integrity bug.

madebycaliper · 14 days ago

Same bug on macOS with 2.1.228. I have the transcripts from both sides of one reassignment: the session that lost its worktree and the session that was handed it. Slugs and paths are redacted as <worktree-A>, <worktree-B>, <repo>; the quoted notices are otherwise verbatim.

Setup

  • Claude Desktop 1.30096.5 (6e1346), built 2026-08-14
  • Claude Code 2.1.228, macOS 26.6
  • 21 worktrees under .claude/worktrees/, ~60 session workspaces on this project, several sessions running in parallel most days

What happened

  • Session A was alive and mid-PR in .claude/worktrees/<worktree-A>. Its tree was clean and its branch was pushed. It sat idle for ~3 days between turns.
  • Session B was an old session from July. Its own worktree (<worktree-B>) had already been recycled while it sat idle.
  • On 2026-08-16 at 20:40 UTC, I resumed session B. To give it a worktree, the pool took session A's — while session A was still using it.
  • Session A found out on its next turn, when its worktree was simply gone.

The two notices

Session B was told it got a "fresh" worktree. It was actually session A's:

The git worktree at <repo>/.claude/worktrees/<worktree-B> was recycled. This session now operates on a fresh worktree at <repo>/.claude/worktrees/<worktree-A>, checked out to the same branch (<branch-of-B>). Absolute paths from earlier in the conversation that reference the old worktree no longer exist — re-read files from the new path as needed.

Session A was told, after the fact:

The git worktree at <repo>/.claude/worktrees/<worktree-A> was deleted. This session now operates on the origin repository at <repo> (defaults to main branch). File paths from earlier in the conversation that reference the worktree no longer exist — re-read files from the origin repository as needed.

Note: when a worktree is recycled, the receiving session is checked out to the same branch the original session had, but the losing session is moved to the repo root on main. This is a second failure: you lose your worktree AND your branch context.

How the victim was picked

  • The pool had 21 worktrees to choose from.
  • 16 of them were dirty (1–58 modified files). Several had been idle since April or May. None were touched.
  • The one taken was clean, fully pushed, and attached to a live session.
  • So the picker seems to ask one question — "is the tree clean?" — and never asks "does a live session still own this?"
  • That gets the incentives backwards: the sessions that commit and push promptly are the ones that get their worktree taken.

Impact

  • No work was lost here — but only because everything had been pushed minutes earlier. The same code path over a dirty tree is the data-loss case in the OP.
  • The receiving session has no idea it evicted a live sibling; the notice calls the worktree "fresh" (related: #85997).
  • There is no way to pin a worktree or keep it out of the pool. The only workaround I've found: put long-lived branches in manual worktrees outside .claude/worktrees/, which the pool never touches.

Repro

  1. Start a session in a pooled worktree. Commit and push everything, then leave it idle a few days mid-task.
  2. Have an old session around whose own worktree was already recycled.
  3. Resume the old session. It gets handed the live session's worktree with the branch switched out; the live session discovers the loss on its next turn.