Worktree isolation binding is session-scoped: any in-process teammate's EnterWorktree/ExitWorktree silently repoints every other agent in the session

Status Open
Reported on v2.1.222
Maintainer reply None cached
Activity 4 comments · opened Aug 6, 2026

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):

  1. 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\>…".
  2. Sibling teammates spawned into the same session land in an unrelated sibling's worktree on their first shell call. One EnterWorktree captured four agents simultaneously (timestamped: the enter at 00:02:36.406, three sibling refusals/relocations within the next 12 seconds).
  3. ExitWorktree by any agent clears the binding for all of them, silently relocating live teammates onto the shared checkout on main mid-task — after a prior pwd had 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.

View original on GitHub ↗

3 Comments

zack-remend · 23 days ago

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):

  1. A COORDINATOR session (no isolation requested, ever) got registered as isolated to one of its builders' worktrees — shared-checkout bash commands refused until a manual ExitWorktree(keep) cleared the binding.
  2. A builder mid-fix got repointed to a ROTATING series of OTHER agents' worktrees — three different worktree names across three consecutive tool calls (observed cycling between two sibling builders' trees). It correctly stopped rather than force git through the guard, at the cost of abandoning a proven two-line fix.

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.

zack-remend · 23 days ago

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.

zack-remend · 23 days ago

Two further shapes from the same box, 2026-08-07 evening — one session's binding flipped SIX times, producing the sharpest hazards yet:

  1. git commit -a executed inside ANOTHER agent's worktree. It was a no-op only by luck: the victim tree's sole change was an untracked file, and -a does 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.
  1. Verification and action landed in DIFFERENT trees within one command sequence. A commit succeeded in the builder's own tree while the immediately following git branch --show-current reported 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.

Showing cached comments. Read the full discussion on GitHub ↗