[WorktreePool] reaps a worktree while its leasing session is actively running (3s after the session's last command)
[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 app1.20186.9(CCD2.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 worktreeevents. - 29/29 attempted removals logged
Removed— nofailed to remove, nocontains 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 logStarting idle timeout for local_<session>: 900sand1800s, suggesting a per-session idle timer in the 15–30 min range.
Questions
- 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. - 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.
3 Comments
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 --forceis what runs, but only after two independent gates:sweep()only makes a worktree a candidate ifisWorktreeClean— which isgit status --porcelain --untracked-files=all, so untracked files count as dirty and protect the tree;removeWorktreeByNamethen re-checks at removal time and bails withRefusing to remove worktree "<n>": uncommitted changes at <path>. Recover work, then remove manually.unless the caller passesallowUnclean. The sweep passes no options.The second gate also closes the scan→reap race I was worried about.
--forcehere 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, andReset dirty worktreeare all absent;Reaping idle worktreeandRemoved worktreesit 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 whenisRunning || query;stillEligibleForSweepre-checksleaseStillMatchesandisRunning || worktreePinned.Both missed a session that was executing a Bash tool call. The pool has a live-occupant check and a
logOccupancySkippath — 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→resetWorktreeToCleanrunsgit reset --hard HEAD+git clean -fd -e .worktree-keepon 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.
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
1.34493.1(CCD2.1.237), MSIX installwin32, x64), Node24.18.1Timeline
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.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 reflogfrom<wt-mine>(the worktree that was not reaped) shows what the pool did to it:Two distinct steps:
rebindWorktreehands 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:
<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
stalelabel 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.logif any of the surrounding GC decisions would help.Independent confirmation of the
resetWorktreeToCleanpath @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
1.52386.3(CCD2.1.266), non-MSIX installMethod
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 everyreason:"delete"release recorded against it, checked that worktree's owngit reflog show HEADfor areset: moving to HEADentry.Result
All 17 release events for this worktree slot correlate with a
reset: moving to HEADin 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 exactlyresetWorktreeToClean'sgit reset --hard HEADfiring — 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 --hardon 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 --harddiscarding 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) beforeresetWorktreeToCleanruns, matching the gatesweep()already has viaisWorktreeClean.Happy to share the underlying
desktop-released.json/reflog timestamp pairs if useful for triage.