Desktop app's worktree pool grants an already-active worktree to a second, concurrent session — registry createdAt proves duplicate allocation, not a reclaim race (distinct from #75911)
Desktop app's worktree pool grants an already-active worktree to a second, concurrent session — registry createdAt proves duplicate allocation, not a reclaim race (distinct from #75911)
Summary
The desktop app's worktree pool (git-worktrees.json, the same registry #75911 documents) assigned a worktree directory to a second, unrelated Claude Code session while a first session was still actively using it — not after the first session finished or went idle, but 14 minutes into a live, ongoing session. The registry's own createdAt timestamp for this slot is stamped essentially the same second the second session's transcript begins, proving the pool's bookkeeping asserted a brand-new lease over a directory that had never been released.
This is filed separately from #75911 because the mechanism is different. #75911 is about reclaiming an existing, previously-registered lease based on a mistimed signal (a worktree gets detached/handed off while its original session is still working, or shortly after). Every incident there involves one worktree changing hands. What happened here is two live sessions holding the same lease at once — the pool's allocation step handed out a path that was already actively in use, and stamped it as freshly created rather than detecting the conflict.
Environment
- Claude Code CLI 2.1.209 (desktop-app-bundled engine)
- Desktop app (Claude.app) 1.20186.9, macOS 15.7.7 (Darwin 24.6.0, arm64)
- Affected repo: a private repo using desktop-app background sessions with worktree isolation (
.claude/worktrees/<name>)
The mechanism, evidenced by the registry itself
Registry entry for the contested worktree (paths/branch names redacted, same convention as #75911):
"<worktree-name>": {
"name": "<worktree-name>",
"path": "/…/<repo>/.claude/worktrees/<worktree-name>",
"leasedBy": null,
"baseRepo": "/…/<repo>",
"branch": "claude/<branch-B>",
"sourceBranch": "main",
"createdAt": 1784064832769,
"pooledAt": 1784068529228
}
Converted:
createdAt: 2026-07-14 21:33:52.769 UTC (17:33:52 EDT)
pooledAt: 2026-07-14 22:35:29.228 UTC (18:35:29 EDT)
- Session A (this worktree's originally-assigned branch,
claude/<branch-A>): first transcript message 21:19:00 UTC. - Session B (the branch now recorded in the registry,
claude/<branch-B>): first transcript message 21:33:53 UTC — one second after the registry'screatedAtfor this exact slot.
Session A had been actively working in this directory — reading files, running tests, issuing tool calls — for 14 minutes before the pool's own bookkeeping claims the worktree was "created." This isn't a race on reclaiming something idle; the registry itself asserts a fresh allocation landed on top of a live one.
Evidence
- The registry snapshot above.
- Independent corroboration from our own session-history log (unrelated to the bug, kept for other reasons): Session A's record begins 21:19:00 UTC in this path; Session B's begins 21:33:53 UTC in the identical path.
- From inside Session A: mid-session,
git status/git branchshowed the worktree had switched fromclaude/<branch-A>toclaude/<branch-B>with no checkout ever issued by Session A. Watching the directory over the following minutes showed real, ongoing writes to multiple source files, one at a time, roughly every 1–2 minutes — Session B's actual, legitimate work landing in real time in a directory Session A believed was exclusively its own. - From inside Session B, independently: it found unexplained test-file additions and an untracked instructions file it hadn't written, and — before being corrected — concluded (in writing, to its own user) that its own spawned review subagent had "overreached its scope" and authored them without permission. This was wrong: those files were Session A's work. It was only corrected because the human operator directly challenged the inference ("are these really from your subagent, or a parallel session that got reaped?"), forcing Session B to reconstruct a file-mtime timeline proving its own subagents had already finished 12+ minutes before the files in question were written.
- Both sessions eventually committed to their own distinctly-named branches from within the same shared directory, pushed successfully, and were later merged to
mainwith no data loss — but only after three separate rounds of manual, cross-session forensic reconciliation by the shared human operator.
Why this evaded detection
- Same root problem as #75911: pool activity produces no tool call visible to either session.
- Worse here: because both sessions were live and doing real, legitimate work, the symptoms didn't read as "unexplained corruption" — they read as plausible, in-scope activity from one's own tooling. Session B's misattribution to its own subagent shows how convincingly this masquerades as a mundane internal explanation rather than an external cause, which makes it strictly harder to catch than a bare detached-HEAD symptom would be.
- Neither session had any signal, lock, or lease-conflict warning indicating another session held the same path.
Suggested fix directions
- Before creating a worktree entry, the pool must check the target path against real git state (
git worktree list) and/or an existing, unexpiredleasedByvalue — not assume a path is free just because a new lease request arrived. createdAtshould mean what it claims. If a path already has live lease state, a competing allocation request should fail loudly (at minimum to the app; ideally surfaced to the session) instead of silently overwriting the registry entry.- Surfacing pool lifecycle events (as #75911 already requests) would help here too, but the more fundamental fix is a uniqueness/liveness check at allocation time — better logging of reclaim events doesn't address a duplicate-allocation bug.
Related issues (not exact duplicates)
- #75911 — same registry/subsystem; different mechanism (reclaim of an existing lease vs. duplicate allocation of a live one).
- #71433 (feature request) — closest conceptual match: cross-session collision on a contested worktree path. Scoped to explicit
EnterWorktreecalls where two sessions independently derive the same name; this report is about the desktop app's own automatic background allocation, with registry-timestamp evidence pinning the exact allocation-time defect. - #77506 — confirms
WorktreePoolas the actual subsystem name; different defect (maintenance sweep detaching the main repo's HEAD during unattended sleep, non-atomic removal). - #74386 — same "no liveness signal" root-cause shape, but for an explicit cleanup action, not automatic silent allocation.
- #76144 — same pool, different corruption (
gitdirwritten as literal.git) feeding its own reclaim/delete path. - #77268 — recycling destroys live sibling worktrees; same family, destructive rather than silent-swap.
- #60896 (closed, stale-bot, not resolved on merits) — same broad bleed-through symptom (HEAD/commits jumping between sessions), different topology (root checkout + one worktree, not worktree + worktree).
- #76727 — broader measurement/research report on cross-session coordination gaps in the same problem space; no specific defect or evidence.
Can provide on request
- Full (unredacted)
git-worktrees.jsonsnapshot and reflogs for both worktrees involved - Session transcript excerpts showing each session's own forensic investigation and the misattribution/correction sequence
3 Comments
Impact note: this bug can make parallel work slower than serial, even for genuinely embarrassingly-parallel splits.
The two colliding sessions in the original report weren't a badly-chosen parallel split — they were deliberately partitioned to have near-zero merge risk (one doing test/fault-injection additions only, the other doing source-only fixes from an independent review). That partition held up: when the two branches were later merged,
git mergeauto-resolved with zero conflicts. This wasn't a case of parallelism being used carelessly.Time accounting from that incident, using each session's own transcript timestamps:
| Scenario | Time | vs. serial baseline |
|---|---|---|
| Reasonably expected parallel time (ideal — same start times, bug never fires) | ~49 min | 36% faster |
| Fully serial (sum of each session's own pure productive time, no overlap, no collision) | ~77 min | baseline |
| Actual: parallel work + detecting the problem, untangling the two sessions' interleaved edits, re-separating them into their correct branches, landing both on
main| ~87 min | 13% slower || Above, plus the forensic investigation needed to work out what had actually happened (reflog reconstruction, correlating the app's own
git-worktrees.jsonregistry timestamps against session-transcript timestamps, anti-duplicate research, and filing this report) | ~117 min | 52% slower |The partition really was near-zero-risk by the normal measure (the eventual
git mergeof the two branches auto-resolved with zero conflicts) — this wasn't parallelism used carelessly. A strategy that should have been a clean ~36% win over serial ended up, once the bug fired, costing more than serial before any diagnostic overhead is even counted, and roughly 50% more than serial once the full cost of figuring out what happened is included. For a bug class whose entire premise is "make concurrent sessions viable," that swing — from a confident +36% expectation to a realized −52% — seems worth surfacing as impact, not just mechanism.This also fires when the "second, concurrent session" is a user-launched
spawn_taskchip, not just two interactive top-level sessions — same duplicate-allocation mechanism, but a higher-stakes trigger worth capturing. A background chip is one of the most common ways to end up with two sessions pointed at one worktree, and it makes the consequence considerably worse.What we saw (2026-07-14). A chip was granted a worktree directory another session was already actively working in. Two fingerprints, consistent with the registry evidence in the original report:
leasedBy: nullafterward), matching the duplicate-allocation-of-a-live-lease signature (not a reclaim race).Why the chip variant is worse — the impact can be genuinely excruciating. When the intruding second session is a chip, two chip-specific failures stack on top of the collision:
Net effect: finished, uncommitted work sitting in a worktree a different live session is actively driving
gitagainst (and that the pool can reclaim at any moment), with zero signal. In our case ~400 lines of correct, completed work survived only because a human later noticed the stale worktree; a routinegit checkout/reset, a checkpoint, or a worktree recycle (#77268 / #74386) would have destroyed it silently. So #77609 isn't only "two interactive sessions collide and one detaches HEAD" — routed through a background chip, it becomes a silent data-loss path.Related: #77661 (chip finishes with uncommitted work — the upstream origin), #76681 (chip completion never notified), #75911 (the reclaim-race sibling).
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.