Desktop app's worktree pool reclaims/re-leases a directory while a session is still using it, detaching HEAD mid-task (and once, on the main clone itself)
Desktop app's worktree pool reclaims/re-leases a directory while a session is still using it, detaching HEAD mid-task (and once, on the main clone itself)
Summary
The Claude desktop app maintains a background pool of reusable git worktree directories, tracked in an internal registry file (git-worktrees.json under the app's Application Support directory, schemaVersion: 2). Each entry has createdAt, pooledAt (when the worktree is returned to the pool for reuse), and leasedBy (which session currently holds it).
Across one ~32-hour window (2026-07-07 19:50 through 2026-07-08 20:32, all times EDT) we captured three separate incidents in one repo where this pool mechanism corrupted a session's git state — in two cases via an unexplained bare git checkout HEAD that detached the worktree while the app's own registry shows the pool reclaiming or re-leasing it, and in one case by silently swapping which branch a pooled directory pointed to between two unrelated sessions. In the worst instance, after a session successfully merged and pushed to main, the main clone's own HEAD detached with the identical fingerprint, unexplained by any tool call the session itself made.
None of this caused permanent data loss — each incident was caught after the fact by the session itself (or a later session) doing a manual forensic check, not by the harness surfacing anything. Nothing about the pool's reclaim/re-lease activity is visible to the session as a tool call, so from inside a session it looks like unexplained git corruption.
Environment
- Claude Code CLI 2.1.202 (desktop-app-bundled engine), macOS (Darwin 24.6.0, arm64)
- Desktop app (Claude.app) 1.19367.0
- Affected repo: a private git repo using desktop-app background sessions (
claude agents) with worktree isolation (.claude/worktrees/<name>)
The mechanism (root cause, as best we can reconstruct from evidence)
The desktop app's git-worktrees.json registry entries look like:
"magical-hofstadter-2816a8": {
"name": "magical-hofstadter-2816a8",
"path": "/…/mneme/.claude/worktrees/magical-hofstadter-2816a8",
"leasedBy": null,
"baseRepo": "/…/mneme",
"branch": "claude/python-app-dock-icon-812914",
"sourceBranch": "main",
"createdAt": 1783541449358,
"pooledAt": 1783542118706
}
createdAt and pooledAt are epoch-ms. Converted:
createdAt: 1783541449358 → 2026-07-08 16:10:49 EDT
pooledAt: 1783542118706 → 2026-07-08 16:21:58 EDT
These two timestamps land exactly on two lines from that session's own git reflog:
16:10:49 checkout: moving from main to claude/python-app-dock-icon-812914 ← matches createdAt
16:21:58 checkout: moving from claude/python-app-dock-icon-812914 to HEAD ← matches pooledAt
checkout: moving from <branch> to HEAD is not something a user or a normal git workflow produces — it's the fingerprint of a bare git checkout HEAD, which detaches HEAD in place. The timing match says the pool's reclaim step (whatever runs at pooledAt) is what performs this detach — and it did so 11 minutes into a session that was still actively working (the session's own commit didn't land until 16:32:35, after the detach). The session had no visibility into this; there is no corresponding tool call in its own transcript. It discovered the problem only because post-commit git status output looked wrong, then manually verified the commit was a clean fast-forward descendant and reattached the branch.
We believe this is a premature-reclaim race: the pool manager decides a worktree is free and detaches/reclaims it based on some signal other than "the session currently leasing it is done," and can fire while the session is mid-task.
Second incident: a worktree that was already broken before the session even touched it
A different registry entry:
"cool-mclean-8e90bf": {
"path": "/…/mneme/.claude/worktrees/cool-mclean-8e90bf",
"leasedBy": "local_e5ff258c-6828-4d96-ab57-ea8b9a1ee8de",
"baseRepo": "/…/mneme",
"branch": "claude/practical-booth-4ac915",
"createdAt": 1783468202717 → 2026-07-07 19:50:02 EDT
}
A session starting at 2026-07-07 23:51 (about 4 hours after this pool slot's createdAt) checked this worktree at 23:52 and found it already HEAD detached at 1ca6a76 — before the session had done anything. Four sequential commits (23:58–00:29) all landed on that pre-existing detached HEAD. The session didn't notice until 04:54:xx, then manually ran git branch -f claude/practical-booth-4ac915 <tip> to reattach.
This looks like the counterpart bug to the first: when the pool re-leases a previously-reclaimed (and therefore detached) worktree directory to a new branch/task, the checkout to the new branch doesn't reliably happen — the new lease inherits the prior lease's detached state.
Third incident: main clone's own HEAD detached after a successful push
The same session (started 23:51, above) merged its branch and pushed to main successfully at 05:12:08 (git push origin main → 8a3b891..513f5e4 main -> main). It then did cleanup (git worktree remove, git branch -d) on other worktrees — not on main. At 05:51:26 it discovered its own main clone now reported HEAD detached at 513f5e4, with reflog:
513f5e4 HEAD@{0}: checkout: moving from main to HEAD
513f5e4 HEAD@{1}: commit (merge): Merge branch 'claude/practical-booth-4ac915' into main
Same bare-HEAD fingerprint, on the primary checkout this time, with no git checkout anywhere in the session's own tool-call history between the push and the discovery. Self-repaired with git checkout main.
We can't yet explain why the pool mechanism would touch the main clone at all — worth noting given the first incident's worktree (magical-hofstadter-2816a8) was, per separate forensics, never actually registered as a real git worktree on disk at all (no .git, git rev-parse --show-toplevel from inside it resolved to the main clone) despite being createdAt/pooledAt-tracked in the app's own registry. If the pool's reclaim logic trusts its own JSON bookkeeping rather than querying real git state before acting, and that bookkeeping can diverge from reality (as it demonstrably did here), it's plausible the same reclaim codepath can end up operating against whatever git rev-parse --show-toplevel resolves to for a broken/unregistered entry — which, for an orphaned worktree, is the main clone.
Fourth incident: a pooled worktree silently changed branch identity between two unrelated sessions
A fourth worktree slot (branch claude/update-13f9cf at creation) was confirmed correctly on that branch by one session at 00:05:27. A second, unrelated session touching the same path at 05:09:14 found it on a completely different branch (claude/spec-md-libreoffice-conversion-32b18f) — no checkout call from either session explains the change. A later git worktree list (05:46:47, from the third session above) found this same path had gone to 8a3b891 (detached HEAD). No data was at risk (the second session made no edits there), but it's the same underlying pattern: the pool reassigned this directory's branch identity mid-flight, outside of any visible session action.
Why this evaded detection until now
- Pool reclaim/re-lease activity produces no tool call visible to the session — from inside a session, this looks like unexplained git corruption, not a documented lifecycle event.
git-worktrees.jsonis an internal, undocumented artifact — nothing in the desktop app UI or CLI docs describes worktree pooling as a concept, so there's no way for a user or an agent session to know this mechanism exists, let alone that it's racing them.- We only found the mechanism by correlating this registry file's
createdAt/pooledAttimestamps against reflog timestamps after already suspecting a race from independent forensic evidence (see repro below).
Suggested fix directions
- The pool reclaim step must not detach/reclaim a worktree while a session still holds an active lease on it — gate reclaim strictly on lease release, not on an independent timer/heuristic.
- Re-leasing a pooled worktree to a new branch must verify the checkout actually succeeded (and that HEAD is attached to the intended branch) before handing it to a new session — currently a failed/incomplete re-checkout silently hands off a broken worktree.
- Whatever performs the reclaim-time detach should verify against real git state (
git worktree list,git rev-parse --show-toplevel) rather than trusting the registry file alone, so a registry entry that was never actually backed by a real worktree can't cause the reclaim logic to act on the wrong checkout (up to and including the main clone). - Consider surfacing pool lifecycle events (
reclaimed,re-leased,detached-for-reuse) as something visible in session transcripts or logs, so a future incident like this is diagnosable from the session side instead of requiring cross-referencing an undocumented internal file.
Related issues (not exact duplicates)
- #75490 — desktop app's worktree mechanism detached the main tree's HEAD unexpectedly (same broad symptom:
checkout: moving from main to <hash>, no user action, worktree-mechanism activity bracketing it in time) and additionally deleted gitignored directories. Same family of "worktree lifecycle mechanism silently mutates a checkout out from under the user," but no pool/lease registry angle and a different (more destructive) blast radius. - #36182, #70456, #66442, #69026, #74071 — an older, separate cluster about tool calls landing in the wrong checkout when worktree isolation silently falls back to the parent clone. Related in spirit (isolation guarantees silently not holding) but a different mechanism — those are about where edits land, not about a background pool detaching/re-leasing a worktree already in active use.
Can provide on request
- Full reflog excerpts and
git-worktrees.jsonsnapshots for all four incidents - Session transcript excerpts showing the absence of any corresponding tool call at each detach point
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗