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)

Status Fixed / completed
Reported on v2.1.209
Maintainer reply None cached
Activity 3 comments · opened Jul 14, 2026 · closed Jul 15, 2026

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's createdAt for 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

  1. The registry snapshot above.
  2. 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.
  3. From inside Session A: mid-session, git status/git branch showed the worktree had switched from claude/<branch-A> to claude/<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.
  4. 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.
  5. Both sessions eventually committed to their own distinctly-named branches from within the same shared directory, pushed successfully, and were later merged to main with 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

  1. Before creating a worktree entry, the pool must check the target path against real git state (git worktree list) and/or an existing, unexpired leasedBy value — not assume a path is free just because a new lease request arrived.
  2. createdAt should 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.
  3. 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 EnterWorktree calls 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 WorktreePool as 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 (gitdir written 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.json snapshot and reflogs for both worktrees involved
  • Session transcript excerpts showing each session's own forensic investigation and the misattribution/correction sequence

View original on GitHub ↗

3 Comments

blwfish · 3 months ago

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 merge auto-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.json registry 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 merge of 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.

blwfish · 3 months ago

This also fires when the "second, concurrent session" is a user-launched spawn_task chip, 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:

  • The victim worktree's own HEAD reflog shows the checkout yanked from the active session's branch onto the chip's branch mid-session — the takeover in a single line.
  • The pool registry entry for that directory was re-associated to the chip's branch (leasedBy: null afterward), 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:

  1. The chip can finish its work without committing it (#77661) — so the duplicate-allocated, contested worktree now holds finished but uncommitted work.
  2. The chip's completion never notifies the launching session (#76681) — so nobody knows the work exists, or that a collision happened at all.

Net effect: finished, uncommitted work sitting in a worktree a different live session is actively driving git against (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 routine git 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).

github-actions[bot] · 1 month ago

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.