Agent worktree discipline¶
When background coding agents (Claude Code, automation runners) work on this repo in parallel, they each get their own isolated git worktree under .claude/worktrees/agent-<id>/. Two things must hold:
- Process side — agents start in, stay in, and commit from their assigned worktree.
- Host side —
gititself refuses commits that come from the main checkout while an agent worktree is active, because that pattern is overwhelmingly the agent landing in the wrong tree.
Run both layers; neither substitutes for the other:
| Layer | Catches | Misses |
|---|---|---|
| 1. Process-side discipline | Drift before any git add, so nothing is staged in the main checkout. Fragile alone: agents drift because their harness's shell state resets, a cd did not survive, or the harness silently retried in a fresh process group. | Any agent that does drift. |
| 2. Host-side hard guard | The commit itself, whenever it comes from the main checkout while an agent worktree is active. | The window between a wrong git add and the hook firing: the files are already staged in the main checkout's index and can confuse the human. |
The local merge-train guide covers the matching ownership boundary for automation: the train refuses an existing owner's checkout and requires an explicit handoff before promotion, rebase, or merge.
The drift pattern¶
"Drift" is when an agent's process ends up running with cwd inside the main checkout (<repo-root>/) instead of its assigned worktree (<repo-root>/.claude/worktrees/agent-<id>/). The agent then git commits into the main checkout's HEAD, which is usually master or whatever branch the human user has checked out. Common consequences:
- The user's uncommitted local edits to the main checkout are clobbered or, worse, mixed into a commit that ends up under the agent's PR.
- The agent's commit lands on the wrong branch (e.g.
master) and has to be cherry-picked off and force-rolled-back, which can race with branch protection. - Two agents collide on the same files in the main tree.
Layer 1 — process-side discipline¶
Never spawn parallel agents in the shared tree (see ADR-0332). Use isolation: "worktree" on the agent task, or pre-create a worktree with git worktree add and pass that path as the agent's cwd.
The canonical pattern an agent should follow:
# At session start — refuse to do anything if cwd drifted.
pwd | grep -q '\.claude/worktrees/' || {
echo "DRIFT: cwd is not inside an agent worktree" >&2
exit 1
}
AGENT_WT="$(pwd)"
# Use absolute paths and `git -C "$AGENT_WT"` for every git call.
git -C "$AGENT_WT" status
git -C "$AGENT_WT" add path/to/file
git -C "$AGENT_WT" commit -m "..."
Agent-side rules of thumb:
- Resolve every path relative to
$AGENT_WT, not viacd+ relative paths. The shell state can reset between bash calls in some harnesses;cddoes not survive. - Verify
git rev-parse --show-toplevelequals$AGENT_WTevery ~20 tool uses. Stop and ask the user if it doesn't. - Never
cdinto the main checkout. If the agent needs to read a file there for inspection, use absolute paths andRead/greprather thancd.
Repository-local Codex hooks¶
Codex reads the tracked .codex/hooks.json file and executes the scripts under .codex/hooks/. Every command resolves the active checkout at execution time:
"$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \
-u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES \
git rev-parse --show-toplevel)/.codex/hooks/<script>.sh"
Do not replace that form with a user-home path, a path copied from another checkout, or a launch-directory-relative .codex/hooks/... command. Agents may start below the repository root and linked worktrees have different absolute paths. Clearing Git's repository-local variables also keeps resolution correct when the command inherits another Git hook's environment. Each target script stays tracked with mode 100755.
Run the portable-path contract after changing either the configuration or a hook target:
The test validates the exact event/matcher matrix, rejects duplicate or extra entries, checks executable Git modes, and runs the safe pre-tool hook from a nested repository directory with a deliberately misleading GIT_WORK_TREE. It deliberately does not execute the session-start hook, which may fetch the configured upstream remote.
Layer 2 — host-side hard guard (ADR-0332)¶
The script scripts/ci/check-agent-worktree-drift.sh runs as a pre-commit hook (the agent-worktree-drift-guard hook in .pre-commit-config.yaml, installed by make hooks-install). It refuses any commit that:
- Originates from the main checkout (
git rev-parse --show-toplevelmatches the repo root), AND - Has at least one sibling agent worktree active under
<repo-root>/.claude/worktrees/agent-*.
That conjunction matters. The guard does not refuse main-checkout commits when no agent worktree is alive — the user's own commits to master from the main checkout pass freely. It only fires when an agent could plausibly be the source of the commit.
Sample blocked-commit error¶
ERROR: agent-worktree-drift guard (ADR-0332) refused this commit.
You are committing to the MAIN checkout while one or more isolated
agent worktrees are active:
/home/user/dev/vmafx/vmafx/.claude/worktrees/agent-deadbeef
/home/user/dev/vmafx/vmafx/.claude/worktrees/agent-cafef00d
... (3 more)
This is the drift pattern documented in
docs/development/agent-worktree-discipline.md — an agent likely
landed in the main tree instead of its assigned worktree and is
about to clobber the user's working state or commit to the wrong
branch.
Bypassing the guard¶
Only a human, in a terminal outside the agent session, may bypass the guard with git's standard escape hatch, git commit --no-verify. An agent must never use it: operational rule 6 of AGENTS.md forbids skipping hooks, and the repository's Bash hook blocks the flag outright.
Legitimate human uses:
- You are committing your own work to the main checkout while a background agent is running.
- You are doing emergency cleanup (for example a
git revertthat has to land on master immediately and the agent worktrees are stale but not yet pruned). - Tooling (release-please, automated bots) commits non-interactively with a known-good cwd.
If you are an agent and the guard fired against your commit, that is the exact pattern the guard exists to catch. cd to your worktree (or use git -C "$AGENT_WT") and re-run.
Installing the hook¶
make hooks-install (an alias of make install-hooks) runs scripts/githooks/install.py (ADR-1241), which installs git-hook dispatchers that work from any worktree and run the .pre-commit-config.yaml hooks, including agent-worktree-drift-guard. It is idempotent: re-running just refreshes the dispatchers.
Testing the guard¶
Four cases run against disposable temp git repos, with no build artifacts and no network:
| Case | Verdict |
|---|---|
| Commit from the agent worktree | allow |
| Main worktree, no sibling agent worktrees | allow |
Main worktree with an empty .claude/worktrees/ | allow |
| Main worktree with an active agent | refuse |
Cleaning up stale agent state¶
scripts/dev/cleanup-agent-state.sh inventories agent worktrees and stashes, and removes finished worktrees only when you select them. With no arguments, or with --dry-run, it reports without changing anything. REVIEW means the checkout passes the local eligibility checks; confirm that its work is complete and no process will write to it before selecting it. KEEP includes the reason a checkout is protected.
-
Inventory first (this is also the behaviour with no arguments):
-
After reviewing this exact checkout and stopping its writers, remove it:
Repeat --worktree to select multiple exact registered paths. --apply without targets, conflicting modes, duplicate targets, relative paths and unknown arguments fail. Every target is checked before the first removal, and each is checked again immediately before Git removes it. If a later target changes, already completed removals are reported and processing stops.
Keep selected worktrees idle
PID checks and Git's normal removal guards do not serialize unrelated processes that start writing during the operation.
What is removed and what is kept¶
The utility only removes an agent-* checkout with a recognised lock reason ending in (pid N) whose recorded process has exited. Git removes eligible worktrees without --force; a failed removal restores the original lock, and branch refs are retained. Everything else is preserved:
| Preserved | Why |
|---|---|
| The main checkout and the current checkout | Never disposable. |
| A checkout with a live or unknown owner | Its process may still write. |
| A checkout with a pending Git operation | A rebase or merge is in progress. |
| Tracked changes | Uncommitted work. |
| Untracked files and ignored artifacts | An ignored file is not proof of redundancy; review and archive useful evidence before separately retiring generated files. |
| A detached HEAD not reachable through a preserving ref | Its commits would be lost. |
| Stashes | Always listed with full object IDs and retained. A branch that still exists does not establish that a stash's changes were committed; inspect the stash and preserve any unique work before a separate, reviewed retirement. |
Cleanup does not expire reflogs or prune objects. It does not classify intentional code scaffolds or ADR reservation stubs as disposable files.
The regression test uses disposable repositories only:
See ADR-1239 for the preservation contract and the migration from the old apply-by-default behaviour.
History¶
Five drift incidents in the 2026-05-09 session prompted the host-side guard described below: PR #498 (AdaptiveCpp), PR #520 (T3-15), PR #526 (ccache), the first attempt at the MCP runtime v2 PR, and the multi-corpus run. Each lost work or required cherry-pick recovery.