A session worktree can check out the default branch and lock the canonical checkout out of it #1086

Closed
opened 2026-08-16 04:01:51 +00:00 by coilyco-ops · 2 comments
Member

Filed by Olaf (OPS). Kai hit this trying to move her deploy checkout back to main:

fatal: 'main' is already used by worktree at
'/private/var/folders/.../T/aos/native/wt57/projects/coilyco-bridge/deploy'

A live session shadow owns main, so the canonical checkout cannot have it. This is a different mechanism from #1084 and strictly worse: that issue is about a swap being skipped, this one makes the swap impossible.

State

Across eleven repositories, main sits in the canonical checkout in ten. In deploy it does not:

/Users/kai/projects/coilyco-bridge/deploy      e51268d [ops/chart-pins-are-watched]
…/aos/native/wt57/projects/coilyco-bridge/deploy  e0622ef [main]      <-- live session
…/aos/native/gq98/projects/coilyco-bridge/deploy  225de4f [aos/claude/gq98]
… nine more, all on aos/claude/<id> or a task branch

How it got there

The lease is the tell. wt57.json records nine artifacts, every one on aos/claude/wt57, including deploy. aos created the worktree correctly. But on disk that worktree is on main, and aos/claude/wt57 no longer exists in deploy.

So an agent inside the shadow did the ordinary thing at the end of a task: merged, switched to main, deleted its branch. In a normal clone that is correct hygiene. In a session worktree it takes the default branch hostage for every other consumer of the repository, and the lease still claims a branch that is gone, so nothing downstream can even tell.

Note this also defeats #1084's fix on its own. Scoping inactive per branch would make the deploy checkout eligible for the swap, and the swap would then fail on this same fatal.

Why it is worse than it looks

  • It is silent until someone tries to use main. Kai's checkout sat three days and 147 commits stale, and the visible symptom was the wrong branch, not the lock.
  • It survives the session. The lease says aos/claude/wt57, so a sweep looking for that branch finds nothing to release and leaves the worktree on main.
  • It scales with concurrency. One agent doing normal cleanup in one of eleven shadows was enough.

Proposed

The canonical checkout should own the default branch inviolably.

  1. Detect and correct at launch. A session worktree found on the default branch gets git switch --detach at the same commit. Non-destructive, frees main immediately, and the agent keeps every file exactly as it was. Cheap and it fixes the existing damage as well as future cases.
  2. Refuse the checkout in the first place, if there is a hook point. A shadow has no business on the default branch: it exists to isolate work onto a session branch.
  3. Reconcile the lease against reality during the sweep. A lease naming a branch that no longer exists, or a worktree on a branch the lease does not name, is currently invisible. Either is a signal that the artifact needs re-reading rather than trusting the record.
  4. Say it in doctrine too. The AGENTS.md rules tell an agent to commit and push from a shadow; nothing tells it not to end by switching to main. A line would have prevented this instance, though the guard is what makes it reliable.

Immediate unblock, for the record

git checkout --ignore-other-worktrees main in the canonical checkout takes main back without touching the live session. Detaching wt57's worktree would be tidier but that session is alive and mid-task, so it is not mine to move.

**Filed by Olaf (OPS).** Kai hit this trying to move her `deploy` checkout back to `main`: ``` fatal: 'main' is already used by worktree at '/private/var/folders/.../T/aos/native/wt57/projects/coilyco-bridge/deploy' ``` A live session shadow owns `main`, so the canonical checkout cannot have it. This is a different mechanism from #1084 and strictly worse: that issue is about a swap being *skipped*, this one makes the swap **impossible**. ## State Across eleven repositories, `main` sits in the canonical checkout in ten. In `deploy` it does not: ``` /Users/kai/projects/coilyco-bridge/deploy e51268d [ops/chart-pins-are-watched] …/aos/native/wt57/projects/coilyco-bridge/deploy e0622ef [main] <-- live session …/aos/native/gq98/projects/coilyco-bridge/deploy 225de4f [aos/claude/gq98] … nine more, all on aos/claude/<id> or a task branch ``` ## How it got there The lease is the tell. `wt57.json` records nine artifacts, every one on `aos/claude/wt57`, including `deploy`. **aos created the worktree correctly.** But on disk that worktree is on `main`, and `aos/claude/wt57` no longer exists in `deploy`. So an agent inside the shadow did the ordinary thing at the end of a task: merged, switched to `main`, deleted its branch. In a normal clone that is correct hygiene. In a session worktree it takes the default branch hostage for every other consumer of the repository, and the lease still claims a branch that is gone, so nothing downstream can even tell. Note this also defeats #1084's fix on its own. Scoping `inactive` per branch would make the `deploy` checkout eligible for the swap, and the swap would then fail on this same `fatal`. ## Why it is worse than it looks - **It is silent until someone tries to use `main`.** Kai's checkout sat three days and 147 commits stale, and the visible symptom was the wrong branch, not the lock. - **It survives the session.** The lease says `aos/claude/wt57`, so a sweep looking for that branch finds nothing to release and leaves the worktree on `main`. - **It scales with concurrency.** One agent doing normal cleanup in one of eleven shadows was enough. ## Proposed **The canonical checkout should own the default branch inviolably.** 1. **Detect and correct at launch.** A session worktree found on the default branch gets `git switch --detach` at the same commit. Non-destructive, frees `main` immediately, and the agent keeps every file exactly as it was. Cheap and it fixes the existing damage as well as future cases. 2. **Refuse the checkout in the first place**, if there is a hook point. A shadow has no business on the default branch: it exists to isolate work onto a session branch. 3. **Reconcile the lease against reality** during the sweep. A lease naming a branch that no longer exists, or a worktree on a branch the lease does not name, is currently invisible. Either is a signal that the artifact needs re-reading rather than trusting the record. 4. **Say it in doctrine too.** The AGENTS.md rules tell an agent to commit and push from a shadow; nothing tells it not to end by switching to `main`. A line would have prevented this instance, though the guard is what makes it reliable. ## Immediate unblock, for the record `git checkout --ignore-other-worktrees main` in the canonical checkout takes `main` back without touching the live session. Detaching wt57's worktree would be tidier but that session is alive and mid-task, so it is not mine to move.
Author
Member

This is worse than locking. A session worktree on the default branch can silently repoint main's upstream for every worktree, including the human's. Olaf (ops, claude seat).

Kai got past the lock, moved her deploy checkout to main, ran git pull, and got a 48-commit rebase with conflicts.

What her repository actually said

branch.main.remote = origin
branch.main.merge  = refs/heads/aos/claude/wt57-retire-profiler
pull.rebase        = true

Her local main was tracking a session branch. With pull.rebase on, git pull on main rebases main onto wt57's work, which is the conflict storm. git status reported "ahead of 'origin/aos/claude/wt57-retire-profiler' by 59 commits" and every reflex reading of that line is wrong.

Measured against the right ref, main was 0 ahead and 24 behind origin/main. Nothing local, nothing at risk, and no reason for a rebase at all.

The mechanism, and why it follows from this issue

Branch configuration is repository-global. Worktrees share it.

wt57's worktree had main checked out, per this issue. An agent in that shadow pushed its work with an upstream-setting push, something of the git push -u origin HEAD:aos/claude/wt57-retire-profiler shape. Because that worktree's HEAD was main, git wrote branch.main.merge in the shared config, and that write reached the canonical checkout.

The corroborating detail: origin/aos/claude/wt57-retire-profiler exists at 36666d1, which is exactly the onto 36666d1 in Kai's rebase output.

Only deploy is affected, and deploy is the only repository where a session worktree holds main. Ten other repositories have main in the canonical checkout and every one tracks origin/main correctly. The correlation is exact.

Severity, revised

I filed this as a lock. It is a config-corruption vector.

  • A lock is loud. fatal: 'main' is already used by worktree tells you what is wrong.
  • This is silent and actively misleading. The symptom is a conflicted rebase against a branch the human never chose, and git status names an unfamiliar branch in a sentence that reads like ordinary divergence. A reader trying to be careful would sooner suspect their own commits than their tracking config.
  • pull.rebase = true makes it destructive-adjacent. A git pull that a human expects to be a fast-forward instead rewrites their branch onto someone else's. Here it was recoverable because nothing local existed. With local commits it would have replayed them onto a session branch.

What that changes about the fix

Proposal 1 stands and gets more urgent: detach a session worktree found on the default branch at launch. It closes the write vector, not just the lock, because a worktree that is not on main cannot cause an upstream-setting push to rewrite branch.main.*.

Worth adding:

  1. Assert branch.main.merge is refs/heads/main on resident repositories during the fleet pass, and repair it. It is a one-line check for a failure that is close to undiagnosable from the symptom, and the pass is already fetching and reading branch state.

Kai's recovery needed no heroics once the cause was visible: rebase --abort, git branch --set-upstream-to=origin/main main, then a clean fast-forward pull.

**This is worse than locking. A session worktree on the default branch can silently repoint `main`'s upstream for every worktree, including the human's.** Olaf (ops, claude seat). Kai got past the lock, moved her `deploy` checkout to `main`, ran `git pull`, and got a 48-commit rebase with conflicts. ## What her repository actually said ``` branch.main.remote = origin branch.main.merge = refs/heads/aos/claude/wt57-retire-profiler pull.rebase = true ``` Her local `main` was tracking **a session branch**. With `pull.rebase` on, `git pull` on `main` rebases main onto wt57's work, which is the conflict storm. `git status` reported *"ahead of 'origin/aos/claude/wt57-retire-profiler' by 59 commits"* and every reflex reading of that line is wrong. Measured against the right ref, `main` was **0 ahead and 24 behind `origin/main`**. Nothing local, nothing at risk, and no reason for a rebase at all. ## The mechanism, and why it follows from this issue **Branch configuration is repository-global. Worktrees share it.** wt57's worktree had `main` checked out, per this issue. An agent in that shadow pushed its work with an upstream-setting push, something of the `git push -u origin HEAD:aos/claude/wt57-retire-profiler` shape. Because that worktree's HEAD *was* `main`, git wrote `branch.main.merge` in the shared config, and that write reached the canonical checkout. The corroborating detail: `origin/aos/claude/wt57-retire-profiler` exists at `36666d1`, which is exactly the `onto 36666d1` in Kai's rebase output. **Only `deploy` is affected**, and `deploy` is the only repository where a session worktree holds `main`. Ten other repositories have `main` in the canonical checkout and every one tracks `origin/main` correctly. The correlation is exact. ## Severity, revised I filed this as a lock. It is a config-corruption vector. - **A lock is loud.** `fatal: 'main' is already used by worktree` tells you what is wrong. - **This is silent and actively misleading.** The symptom is a conflicted rebase against a branch the human never chose, and `git status` names an unfamiliar branch in a sentence that reads like ordinary divergence. A reader trying to be careful would sooner suspect their own commits than their tracking config. - **`pull.rebase = true` makes it destructive-adjacent.** A `git pull` that a human expects to be a fast-forward instead rewrites their branch onto someone else's. Here it was recoverable because nothing local existed. With local commits it would have replayed them onto a session branch. ## What that changes about the fix Proposal 1 stands and gets more urgent: **detach a session worktree found on the default branch at launch.** It closes the write vector, not just the lock, because a worktree that is not on `main` cannot cause an upstream-setting push to rewrite `branch.main.*`. Worth adding: 5. **Assert `branch.main.merge` is `refs/heads/main` on resident repositories** during the fleet pass, and repair it. It is a one-line check for a failure that is close to undiagnosable from the symptom, and the pass is already fetching and reading branch state. Kai's recovery needed no heroics once the cause was visible: `rebase --abort`, `git branch --set-upstream-to=origin/main main`, then a clean fast-forward `pull`.
Author
Member

Four of five proposals are in #1088. Proposal 4 is blocked, and its text is parked where it can be picked up. Olaf (ops, claude seat).

Proposal State
1. Detach a worktree on the default branch #1088, live sessions included
2. Refuse the checkout at source folded into 1 and 4 - git offers no hook point, so the launch-time detach plus the rule is the whole of it
3. Reconcile lease against reality #1088
4. Doctrine line blocked on #1089, text written and ready to paste
5. Assert branch.main.merge #1088

The diagnosis in this issue was incomplete

Worth correcting here rather than leaving it for a reader to trip over. I wrote that the fleet pass would have failed on the same fatal once #1084's inactivity scoping was fixed. True, but it was never reaching that point.

nativePathKey resolves through EvalSymlinks, which fails on an absent path. add() counted that as uncertain identity and set live.uncertain, which makes contains() answer true for every path. A single purged session worktree therefore disabled the entire pass, and the oldest such lease has been dead since 2026-08-05. So the pass was not skipping this repository, it was doing nothing at all, everywhere. Fixed in #1088 and written up in docs/native-default-branch.md.

Why proposal 4 still matters with 1 and 5 landing

The guards are launch-time repairs. They fix a shadow that already took main, on the next launch. In the window between an agent taking the branch and that launch, the operator's checkout is locked and their branch.main.merge is still wrong.

The rule is what stops it happening rather than un-happening it. Nothing in the base tells an agent that finishing the ordinary way - merge, switch to main, delete the branch - is harmful in a shadow, and that is a thoroughly trained habit. It is the entire cause of this incident.

Text and exact line budget are on #1089.

**Four of five proposals are in #1088. Proposal 4 is blocked, and its text is parked where it can be picked up.** Olaf (ops, claude seat). | Proposal | State | | --- | --- | | 1. Detach a worktree on the default branch | **#1088**, live sessions included | | 2. Refuse the checkout at source | folded into 1 and 4 - git offers no hook point, so the launch-time detach plus the rule is the whole of it | | 3. Reconcile lease against reality | **#1088** | | 4. Doctrine line | **blocked on #1089**, text written and ready to paste | | 5. Assert `branch.main.merge` | **#1088** | ## The diagnosis in this issue was incomplete Worth correcting here rather than leaving it for a reader to trip over. I wrote that the fleet pass would have failed on the same `fatal` once #1084's inactivity scoping was fixed. True, but it was never reaching that point. `nativePathKey` resolves through `EvalSymlinks`, which fails on an absent path. `add()` counted that as uncertain identity and set `live.uncertain`, which makes `contains()` answer true for **every** path. A single purged session worktree therefore disabled the entire pass, and the oldest such lease has been dead since 2026-08-05. So the pass was not skipping this repository, it was doing nothing at all, everywhere. Fixed in #1088 and written up in `docs/native-default-branch.md`. ## Why proposal 4 still matters with 1 and 5 landing The guards are launch-time repairs. They fix a shadow that already took `main`, on the next launch. In the window between an agent taking the branch and that launch, the operator's checkout is locked and their `branch.main.merge` is still wrong. The rule is what stops it happening rather than un-happening it. Nothing in the base tells an agent that finishing the ordinary way - merge, switch to `main`, delete the branch - is harmful in a shadow, and that is a thoroughly trained habit. It is the entire cause of this incident. Text and exact line budget are on #1089.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os#1086
No description provided.