Worktree isolation binding is session-scoped: any in-process teammate's EnterWorktree/ExitWorktree silently repoints every other agent in the session
Version: Claude Code 2.1.222 (also observed on 2.1.220/2.1.221) · macOS arm64 · CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
Summary
When a subagent spawned as an in-process teammate (Agent with a name, no cwd, no isolation) calls EnterWorktree, the worktree binding applies to the whole shared session rather than to the calling agent. Observed consequences (9 occurrences in one working day, established from transcript forensics with timestamps):
- The parent session — which never called
EnterWorktree— becomes worktree-isolated to the child's worktree, and its shell calls are refused with "This session is isolated in the worktree \<path\>…". - Sibling teammates spawned into the same session land in an unrelated sibling's worktree on their first shell call. One
EnterWorktreecaptured four agents simultaneously (timestamped: the enter at 00:02:36.406, three sibling refusals/relocations within the next 12 seconds). ExitWorktreeby any agent clears the binding for all of them, silently relocating live teammates onto the shared checkout onmainmid-task — after a priorpwdhad correctly confirmed the worktree. Read-only git then returns well-formed wrong answers (a "branch diff" that is silently main-vs-main), and writes would land in the shared checkout. In the observed case the relocated agent was mid-rebase.
The binding is deterministic last-writer-wins on one shared mutable slot per session — not a race. The tool's own success text already distinguishes the two scopes:
| Caller shape | Success text | Scope |
|---|---|---|
| subagent with a cwd override (spawnDepth ≥ 1, explicit cwd) | "This agent's working directory and write access now point at the worktree; the previous directory was left untouched." | agent-local — correct |
| in-process teammate (spawned with name, no cwd/isolation) | "The session is now working in the worktree." | session-wide — the bug |
Guard asymmetry
A guard exists for the adjacent case — EnterWorktree{name} from a cwd-override subagent is refused with "it would mutate the parent session's process-wide working directory" — but its precondition is inverted relative to the hazard: the isolated, safe caller is blocked, while the in-process teammate that actually shares the parent's session is allowed through, whereupon it does exactly what that message warns about.
Expected
Worktree binding is keyed on the calling agent. A session is repointed only by an EnterWorktree originating from that session's own top-level agent, and one agent's ExitWorktree never relocates another.
Actual
One shared mutable binding per session, last-writer-wins, cleared by anyone.
Suggested fix
Give in-process teammates the same agent-local treatment cwd-override subagents already get. If session-scoped binding must remain on some path, gate both EnterWorktree and ExitWorktree on the call originating from the session's top-level agent, and make a teammate's call a no-op on the session binding.
Severity
Silent-wrong-answer and mutation risk, not just interruption. A relocated agent's reads are plausible and wrong (nothing distinguishes a main-vs-main diff from the branch diff it was asked for), and its writes hit the wrong tree. Additionally, a bound session is not visibly bound: plain single commands pass straight through while bound; only compound / multi-line / source shapes trip the containment check — in a 43-minute bound window ~80 commands ran and exactly two were refused, which is why this presented as an intermittent "race" for days.
Minimal repro
Reproduces the session-scoped binding in a throwaway repo:
mkdir -p /tmp/wt-repro/repo && cd /tmp/wt-repro/repo
git init -q -b main && echo hello > file.txt
git add -A && git -c user.email=r@r -c user.name=r commit -qm init
claude --permission-mode bypassPermissions
Then paste as one prompt:
Do exactly this and nothing else. (1) Use Bash to run:pwd— record as BEFORE. (2) Use the Agent tool with subagent_type general-purpose, name childwt-agent, run_in_background false, and child prompt: "Call the EnterWorktree tool with name set to childwt2. Then use Bash to run: pwd. Report the EnterWorktree result text verbatim and the pwd output. Do nothing else." (3) After the child returns, use Bash to run:pwd— record as AFTER, and if the Bash call is refused paste the FULL refusal text. (4) Reply with exactly three lines: BEFORE / CHILD / AFTER. You must never call EnterWorktree or ExitWorktree yourself.
Observed: the child's EnterWorktree succeeds and returns "Created worktree at …/.claude/worktrees/childwt2 … The session is now working in the worktree." The parent never called EnterWorktree.
Honest limit: the clean-room repro reproduces the binding but not the parent-side refusal — in a fresh git init repo the bound parent's commands kept running normally, including the compound/multi-line shapes refused in the large live repository (~90 pre-existing worktrees). Some precondition present in the large repo gates whether the bound parent gets blocked; not isolated. The four consequence shapes above are established from live transcript forensics (timestamps, a control of ~80 same-shape commands succeeding while unbound, four independent agents), not from the clean-room repro.
Workaround (for anyone else hitting this)
cwd flips between tool calls, so a separate pwd check proves nothing. Every git operation and file mutation from a teammate becomes one atomic bash call: cd <abs worktree> && test "$(git rev-parse --abbrev-ref HEAD)" = <expected-branch> || exit 1 followed by the real work in the same call. Avoidance: brief teammates to use plain git worktree add + cd instead of EnterWorktree, or spawn them with an explicit cwd — both route onto the correctly-scoped agent-local path.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Recurrence evidence, 2026-08-07 (~15:40-16:00 ET), same box — the race now degrades sessions in two distinct modes within one hour, correlating with rapid worktree creation (5+ isolation worktrees minted that day):
The refusal guard itself is doing its job (it prevents a real file-destruction class); the REGISTRATION is what races. Restating the headline ask with this evidence: bind isolation per-AGENT (session-id/agent-id key with an atomic claim), not session-scoped — cwd-override subagents already get this right.
Escalation exhibit, same box, 2026-08-07 later afternoon — the race now produces FALSE-GREEN VERIFICATION: an isolation builder ran vitest and got a green exit code whose RUN header named ANOTHER agent's worktree — its own two new tests never executed. An eslint pass and a git add/commit pair in the same window also executed against the wrong tree (both trees verified uncontaminated afterward). The builder caught it only by asserting pwd per call and reading vitest's RUN header instead of trusting exit codes.
This upgrades the impact class from lost work to verification integrity: with the session-scoped binding racing, a green suite exit today does not prove WHICH TREE it graded. A test-then-commit pipeline that trusts exit codes can certify and ship an unmodified tree while the real change goes ungraded.
Two further shapes from the same box, 2026-08-07 evening — one session's binding flipped SIX times, producing the sharpest hazards yet:
git commit -aexecuted inside ANOTHER agent's worktree. It was a no-op only by luck: the victim tree's sole change was an untracked file, and-adoes not stage untracked. A tracked modification would have been committed onto the victim's branch under the wrong agent's commit message — cross-agent history contamination with no error anywhere.git branch --show-currentreported the OTHER tree — so with the binding racing, FAILURE/state reports are as unreliable as green ones; a caller reading that branch output would have concluded its commit vanished.Both trees verified uncontaminated afterward. Working mitigation on this box, for other affected users: tree identity (
git rev-parse --show-toplevel) asserted in the SAME shell call as any evidence-producing or mutating command — separate calls prove nothing when the pin can flip between them.