[WorktreePool] reaps a worktree while its leasing session is actively running (3s after the session's last command)

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

[WorktreePool] removed a worktree while the session leasing it was actively running — 3 seconds after that session's last successful command. The removal log line names the live lease and removes it anyway.

Nothing was lost in my case: the session had committed and pushed seconds earlier. The same event with uncommitted work in the tree would be silent data loss, and I can't find anything that would have prevented it.

Environment

  • Claude Code 2.1.212, desktop app 1.20186.9 (CCD 2.1.209)
  • macOS 26.5.1 (Apple Silicon)
  • Worktrees under the hardcoded <repo>/.claude/worktrees/
  • High concurrency: ~10 local sessions against one repo

What the log shows

From ~/Library/Logs/Claude/main.log (timestamps local, UTC-5):

20:00:48 [info] [WorktreePool] Reaping idle worktree <wt-a> in /Users/<user>/projects/<repo>
20:00:48 [info] Removing worktree "<wt-a>" (leased by local_c0eba6de-…): /Users/<user>/projects/<repo>/.claude/worktrees/<wt-a>
20:00:48 [info] Removed worktree "<wt-a>"
20:00:48 [info] [WorktreePool] Reaping idle worktree <wt-b> in /Users/<user>/projects/<repo>
20:00:48 [info] Removing worktree "<wt-b>" (leased by local_ceadb07b-…): …
20:00:49 [info] Removed worktree "<wt-b>"

Both worktrees were leased by sessions that were alive. Two live sessions died in one pass.

The session was demonstrably not idle

The victim session's own transcript brackets the reap (times UTC; 20:00:48 local = 01:00:48Z):

| Time (UTC) | Event |
|---|---|
| 01:00:37 | Bash: git push -u origin <branch> + gh pr create |
| 01:00:41 | Succeeds — PR created |
| 01:00:45 | Bash: gh pr view <n> --json autoMergeRequest |
| 01:00:48 | [WorktreePool] Reaping idle worktree → Removed |
| 01:01:03 | failed to run git: fatal: Unable to read current working directory: No such file or directory |
| 01:01:11 | Working directory "…/.claude/worktrees/<wt-a>" was deleted; shell cwd recovered to "/Users/<user>" |

A Bash tool call was in flight when the worktree was removed. Whatever idle means to the pool here, it does not mean "no active work" — and it isn't the lease, since the lease is printed on the very line that removes it.

The worktree had been created by the app ~36 minutes earlier for this same session, which ran near-continuously throughout (including a 241-second test run — possibly relevant if idle is measured by something that a long-running tool call doesn't refresh).

Frequency

In a single day's main.log:

  • 29 Reaping idle worktree events.
  • 29/29 attempted removals logged Removed — no failed to remove, no contains modified files, no refusal of any kind.
  • Not on a fixed schedule: reaps at 06:08 07:38 12:07 17:53 18:23 19:30 20:00. Neighbouring [WarmLifecycle] lines log Starting idle timeout for local_<session>: 900s and 1800s, suggesting a per-session idle timer in the 15–30 min range.

Questions

  1. Should [WorktreePool] reap a worktree whose lease is held by a live session? From the outside this reads like idle-detection consulting a timer that an active session doesn't reset, while the lease check is logged but not enforced.
  2. Does the removal use git worktree remove --force? This is the part I couldn't determine and it decides the blast radius. 29/29 removals succeeding with zero refusals is consistent either with --force (which discards uncommitted edits) or with no dirty tree ever having been reaped. If it force-removes, any uncommitted work in a reaped worktree is gone with no warning and no recovery.

Impact

<repo>/.claude/worktrees is hardcoded (function lAt(e){return tu.join(e,".claude","worktrees")}) with no setting to relocate it, so users can't move worktrees somewhere the pool won't reach. On this machine the only workable mitigation has been "commit and push immediately, always" — an active session is not protected by being active.

Happy to supply fuller log excerpts if useful.

View original on GitHub ↗

3 Comments

jeffhamons · 3 months ago

Correction from the reporter — I overstated the risk in "Questions" #2, and I want that on the record. I read the implementation in app.asar. Two claims of mine were wrong:

1. The idle sweep cannot destroy uncommitted work. I said "assume the worst" on --force. git worktree remove --force is what runs, but only after two independent gates:

  • sweep() only makes a worktree a candidate if isWorktreeClean — which is git status --porcelain --untracked-files=all, so untracked files count as dirty and protect the tree;
  • removeWorktreeByName then re-checks at removal time and bails with Refusing to remove worktree "<n>": uncommitted changes at <path>. Recover work, then remove manually. unless the caller passes allowUnclean. The sweep passes no options.

The second gate also closes the scan→reap race I was worried about. --force here is gated, not reckless.

2. My "29/29 removals, zero refusals" was bad evidence. That is explained by the clean gate, not by force-removal. In a full day: Refusing to remove worktree, despite uncommitted changes, and Reset dirty worktree are all absent; Reaping idle worktree and Removed worktree sit at 37/37. Only clean trees ever reach removal, so the removal call never has to refuse. I inferred force from an absence that the design fully explains.

What still stands — and it is sharper than what I filed. The defect is session-liveness tracking, not reap policy:

  • findLiveOccupant(path) only counts a session when isRunning || query;
  • stillEligibleForSweep re-checks leaseStillMatches and isRunning || worktreePinned.

Both missed a session that was executing a Bash tool call. The pool has a live-occupant check and a logOccupancySkip path — it is trying to do the right thing and the liveness signal is what fails it.

The root cause is worth stating plainly, because it is counterintuitive: the session committed and pushed at 01:00:37–01:00:41, which made the worktree clean — and that is exactly what made it a reap candidate 7 seconds later. The safety gate and the trigger are the same predicate. Nothing was lost precisely because the tree was clean, so the blast radius here is a live session losing its cwd mid-command, not data loss.

One genuine data-loss path I did find, separate from the sweep: releaseOrRemoveLocked → resetWorktreeToClean runs git reset --hard HEAD + git clean -fd -e .worktree-keep on a dirty worktree to recycle it into the pool. It never fired in my logs, but it discards tracked and untracked edits. Whether that is intended for the release path is your call — flagging it since I was in the code.

Net: severity is lower than I filed it, the bug is real and better localized. Apologies for the noise on #2.

crizin · 1 month ago

Same root cause on Windows, and without the high-concurrency setup — an app restart is enough to trigger it. Adding this because the existing reports are all macOS under heavy parallel load, which makes this look like a contention-only bug. It isn't.

There's also a second-order effect I haven't seen described: the reaped session recovers by taking over another session's worktree, checking its own branch out over that session's checkout. So one bad reap damages two sessions, not one.

Environment

  • Desktop app 1.34493.1 (CCD 2.1.237), MSIX install
  • Windows 11 (win32, x64), Node 24.18.1
  • 4 registered worktrees in the repo — nowhere near the ~10–18 in #78350 / #77268
  • All sessions idle for ~2 days; the app had been fully closed and was reopened that morning

Timeline

App start, then the pool GC runs 63 seconds later. Paths and names masked; <wt-mine> is the worktree I was working in, <wt-victim> belongs to an unrelated session.

09:38:31 [info] Starting app { appVersion: '1.34493.1', platform: 'win32' }
09:38:31 [info] [CCD] Initialized with version 2.1.237

09:39:34 [info] [WorktreePool] untracked-dir GC: 1 untracked dir(s), 1 past min age, 0 removed
09:39:44 [info] [WorktreePool] Reaping idle worktree <wt-victim> in <repo>
09:39:44 [info] Removing worktree "<wt-victim>" (leased by local_82e1ec43-…): <repo>\.claude\worktrees\<wt-victim>
09:39:56 [info] Removed worktree "<wt-victim>"

09:55:03 [warn] Cannot start shell PTY for session local_82e1ec43-…:
                cwd "<repo>\.claude\worktrees\<wt-victim>" does not exist
09:55:06 [info] [rebindWorktree] Rebound <repo>\.claude\worktrees\<wt-mine>
                (was leased by none) to local_82e1ec43-… on branch <branch-B>

10:15:18 [info] [rebindWorktree] Rebound <repo>\.claude\worktrees\<wt-fresh>
                (was leased by none) to local_25837629-… on branch <branch-A-recorded>
10:15:18 [info] [CCD] Worktree at <repo>\.claude\worktrees\<wt-mine> was reaped;
                re-leased <repo>\.claude\worktrees\<wt-fresh> on branch <branch-A-recorded>

Same Reaping idle worktree → Removing worktree "…" (leased by …) pair as the original report: the removal line names a live lease and removes it anyway. Here the lease belonged to a session that was merely idle, not running — but it was still a valid lease, and the session came back 15 minutes later to find its cwd gone.

The cascade

git reflog from <wt-mine> (the worktree that was not reaped) shows what the pool did to it:

HEAD@{09:39:43}: checkout: moving from <branch-A-actual> to HEAD
HEAD@{09:55:03}: checkout: moving from <sha> to <branch-B>

Two distinct steps:

  1. 09:39:43 — the same GC pass detaches HEAD in unleased worktrees, releasing the branches they held. My worktree was "unleased" only because my session had been idle across the app restart.
  2. 09:55:03 — the session whose worktree was destroyed at 09:39:44 wakes up, finds no cwd, and rebindWorktree hands it my now-unleased worktree, checking <branch-B> out over my checkout.

So the reap at 09:39:44 damaged two sessions: the one whose directory was deleted, and the one whose directory was repurposed to repair it.

What this cost, and what it would cost

Nothing tracked was lost — my three commits were already pushed. Two things are worth flagging anyway:

  • **The rebind restores the session's recorded branch, not the branch it was actually on.** My session was registered against <branch-A-recorded> but I had created and worked on <branch-A-actual> mid-session (git checkout -b). The rebind at 10:15 put the fresh worktree on <branch-A-recorded> at the session's starting commit, so from inside the session all the work looked gone. It's recoverable if you pushed; it is not obvious that you need to.
  • .gitignored files in the worktree are unprotected. Pushing doesn't save them, and they are exactly what a long session accumulates — generated drafts, scratch output, local notes. In my case a generated document at the repo root would have had to be reconstructed by hand.

Transcripts do survive — [CCD] Migrated transcript <uuid> from <old-worktree-project-dir> to <new-worktree-project-dir> is logged and worked correctly.

Why I think this is worth reopening rather than filing separately

The stale label suggests this looked like a niche high-concurrency issue. The trigger here is just close the app, reopen it the next day with a handful of worktrees, on a different OS — which suggests the idle/lease check is time-based and doesn't distinguish "lease held by a session that is currently detached" from "lease abandoned". If the reap respected live leases, the rebind cascade would not happen either, so both symptoms have the same fix.

Happy to pull more of main.log if any of the surrounding GC decisions would help.

redsand2 · 1 month ago

Independent confirmation of the resetWorktreeToClean path @jeffhamons flagged above, on Windows, from a different mechanism than the idle-sweep this issue originally reported: an ordinary session-end release, not idle detection.

Setup

  • Desktop app 1.52386.3 (CCD 2.1.266), non-MSIX install
  • Windows 10 Home 19045, x64
  • One actively-used repo whose worktree pool has 197 distinct named slots accumulated over several weeks (counted from the session-transcript directory), sessions ending and new ones starting continuously

Method

The harness writes <session-id>.desktop-released.json ({"reason":"delete", "releasedAt": <ISO ts>}) next to each session's transcript when a session ends. I picked one worktree slot and, for every reason:"delete" release recorded against it, checked that worktree's own git reflog show HEAD for a reset: moving to HEAD entry.

Result

All 17 release events for this worktree slot correlate with a reset: moving to HEAD in its own reflog. 16 of the 17 resets are timestamped after their release, 0.28–15.7 s later (15 of those within 4 s); the 17th release and its reset land in the same one-second reflog bucket, so sub-second ordering isn't resolvable there. This is exactly resetWorktreeToClean's git reset --hard HEAD firing — on plain session end, unconditionally, not only inside the idle-reap sweep. So the answer to "does that path actually fire" is yes, on every ordinary release in this sample, on a second install/OS combination than the one already in this thread.

I can't confirm byte-for-byte data loss after the fact (the reset already ran), but the timing between the last commit and the reset is the relevant risk window. Across these 17 pairs it ranges from under a minute to 56 hours (median 6.0 h); 10 of 17 exceed one hour, 8 exceed six hours, 2 exceed a full day. A worktree that goes a day with no intervening commit and is then reset --hard on release is exactly the scenario #78350 and #77268 already describe as silent loss — this is the same mechanism, just triggered by normal exit instead of an idle timer misfiring on a live session.

Why I think this belongs here rather than a new issue

Same code path (releaseOrRemoveLocked → resetWorktreeToClean), same effect (git reset --hard discarding a dirty worktree with no confirmation). The distinction from the top-level report is only the trigger: idle-sweep-on-a-live-session vs. reset-on-legitimate-release. Both point at the same fix — a dirty-tree check (or at minimum a warn-and-preserve path) before resetWorktreeToClean runs, matching the gate sweep() already has via isWorktreeClean.

Happy to share the underlying desktop-released.json/reflog timestamp pairs if useful for triage.