Git Worktree Isolation
Agents Fleet uses Git worktrees to give file-mutating workers isolated file system environments for safe parallel editing.
Overview
When the coordinator spawns a worker whose effective permission profile can run shell commands or write without scope restrictions, it automatically gets its own Git worktree:
- Shell-capable or unrestricted-write workers → isolated worktree at
.worktrees/{agentId}/on branchworker/{agentId} - Read-only and scoped write-only workers → share the main working directory
The decision uses the effective profile after wildcard, type-specific, and spawn overrides. Shell is treated as write-capable even when canWrite is false, because commands can modify files directly. This includes tester workers and restricted workers explicitly elevated under SEC-4.
An explicit spawn cwd takes precedence and disables automatic worktree creation. That preserves compatibility for workflows that intentionally select a directory, but it also means shell commands operate in that shared directory. This is especially important on Windows, where an explicit shared path does not gain isolation merely because the effective profile was elevated.
Key Features
- Automatic creation — worktrees are created on worker spawn, no user action needed
- Fail-closed isolation — when an effective profile requires isolation and worktree creation fails, the worker fails before provider startup instead of falling back to the shared working tree
- Auto-merge on completion — successful workers have their changes merged back
- Conflict detection — merge conflicts are caught and handled gracefully
- Worktree preservation on failure — failed workers' worktrees are kept for inspection
- Max 2 merge resolution attempts — prevents infinite conflict loops (DoD-31/32)
How It Works
WorktreeManager
The WorktreeManager is responsible for the lifecycle of Git worktrees:
- Create —
git worktree add .worktrees/{agentId} -b worker/{agentId} - Remove —
git worktree remove .worktrees/{agentId}+ branch cleanup - Merge — fast-forward or three-way merge back to the source branch
- List — enumerate all active worktrees
FleetManager Integration
The FleetManager hooks into worker lifecycle events:
Worker Spawn
→ WorktreeManager.create(agentId)
→ SDK session receives worktree path as workingDirectory
→ Worker operates in isolated directory
Worker Complete (success)
→ WorktreeManager.merge(agentId)
→ Worktree removed on successful merge
→ Branch cleaned up
Worker Complete (failure)
→ Worktree preserved for inspection
→ Warning logged for userSDK Working Directory
The SDK's workingDirectory parameter passes the worktree path to the worker session. This means the worker's file operations (read, write, edit, bash) all operate within the isolated worktree.
Merge Conflict Flow
When a merge conflict is detected:
Merge Attempt
→ Conflict detected
→ Abort merge (git merge --abort)
→ Preserve worktree for manual resolution
→ Block the task (mark as blocked)
→ Notify coordinator of conflict
Coordinator may retry (up to 2 attempts)
→ If still conflicted after 2 attempts → permanent blockA maximum of 2 merge resolution attempts are allowed before the task is permanently blocked (DoD-31/32). This prevents infinite conflict resolution loops.
Commands
/worktree (alias /wt) defaults to list. Usage: /worktree [list | cleanup [--all] [--force] [--yes] [--allow-unverifiable] | status].
Classification
Every worktree is classified by reconciling git worktree list --porcelain against the directories on disk. Classes are disjoint and checked in this order:
| Class | Meaning |
|---|---|
unregistered-dir | Directory exists under the worktrees root but git has no record of it |
missing-dir | Git has a record but the directory is gone — remedied with git worktree prune, never rm |
registered-prunable | Git itself reports the entry as prunable |
nested | Registered and present, but not a direct child of the worktrees root (it lives inside another worktree). Never an orphan, never removed automatically |
healthy | Registered, present, a direct child of the worktrees root |
Every non-healthy, non-nested entry is an orphan candidate. Whether a local branch still exists is shown as a hint only; it plays no part in classification.
Refusal reasons
A candidate carrying any refusal is never deleted — by /worktree cleanup or by the startup reaper:
| Refusal | Why |
|---|---|
main-checkout | The path is the repository's main checkout |
locked | git worktree lock is set |
dirty | git status --porcelain reports uncommitted files |
unmerged | The branch/HEAD has commits not reachable from the base branch |
live-agent | A spawning/running/idle agent in this session has its cwd at or inside the path (realpath-compared), or another live process claims it via an owner marker (see Owner marker). Enforced by /worktree and the startup reaper alike, and re-checked at the moment of deletion |
nested | The entry is a nested worktree |
contains-worktrees | Other registered worktrees live beneath this path; deleting it would orphan them |
self | The current process is running inside this worktree (from process.cwd(), GIT_WORK_TREE or GIT_DIR, containment-aware). Applies even to out-of-process callers, and is re-checked at the moment of deletion |
unverifiable-dirty | The directory holds content beyond git metadata (a .git file is exempt; a .git directory counts as content), but its dirty state cannot be probed (typical for unregistered-dir / registered-prunable), so uncommitted work cannot be ruled out. Only /worktree cleanup --force --allow-unverifiable overrides it, after you've inspected the directory; the startup reaper never does |
unverifiable-unmerged | The unmerged-commit probe could not run (the branch/HEAD could not be resolved or git log failed), so unmerged commits cannot be ruled out. No flag overrides it |
symlink | The entry under the worktrees root is a symlink or junction. It is never followed or deleted automatically — remove the link by hand |
/worktree list
Lists every worktree (worker, compete and hand-named) with its class and safety signals:
Worktrees:
worker-5 · unregistered-dir
path: D:\repo\.worktrees\worker-5
branch: (none)
unmerged: 0 commit(s) dirty: probe unavailable locked: no live agent: no
REFUSED: unverifiable-dirty
worker-7 · healthy
path: D:\repo\.worktrees\worker-7
branch: worker/worker-7 (branch exists)
unmerged: 2 commit(s) dirty: 0 file(s) locked: no live agent: no
REFUSED: unmerged
2 worktree(s) total, 1 orphan candidate(s)/worktree uses the session's own WorktreeManager, so live agent: reflects the workers running in this session (and worktrees stamped by other live fleet processes), and the configured worktrees.directory is the one scanned. When an owner marker is the reason, an owner: line shows its pid, host and creation time plus the marker path.
Owner marker
Every worktree created by WorktreeManager is stamped with <git-common-dir>/worktrees/<name>/agents-fleet-owner.json (pid, hostname, createdAt) — inside git's per-worktree admin dir, never the working tree, and deleted by git on git worktree remove / prune. It is written atomically and only read when the worktree's .git file points inside <git-common-dir>/worktrees/ (UNC paths are rejected). A marker counts as a live owner when:
- it names another pid on this host that is still running (a dead pid is stale; your own pid is ignored);
- it names another host (its pid cannot be checked) — live until the marker file is older than the staleness bound;
- it is unreadable, oversized (≥ 4 KB) or unparseable — live until older than the staleness bound.
The staleness bound is AGENTS_FLEET_WORKTREE_REAP_RETENTION_DAYS (default 7; 0 falls back to 7). Re-using a worktree whose marker is held by another live owner fails rather than overwriting it. Escape hatch: if you know the owner is gone, delete <git-common-dir>/worktrees/<name>/agents-fleet-owner.json by hand. Only worktrees created by WorktreeManager carry a marker.
/worktree cleanup
Dry run by default (since v0.59.0) — it previews what would be removed and deletes nothing:
Dry run — nothing deleted (worker scope).
...per-candidate report...
1 of 2 candidate(s) would be removed; 1 refused.
Re-run with --force to delete the reclaimable entries.| Invocation | Effect |
|---|---|
/worktree cleanup | Preview worker/* worktrees (plus directories with no resolvable branch); nothing deleted |
/worktree cleanup --force | Delete the reclaimable worker/* entries; refused entries (including unverifiable-dirty) are kept |
/worktree cleanup --force --allow-unverifiable | As above, and also delete unverifiable-dirty entries — your explicit acknowledgement that their contents may be lost. Every other refusal still applies. Ignored without --force (a dry run never waives anything) |
/worktree cleanup --all | Preview all worktrees, including compete/* and hand-named trees |
/worktree cleanup --all --force --yes | Delete all reclaimable worktrees. --yes only confirms the --all scope — --all --force without it is rejected; it does not waive unverifiable-dirty (add --allow-unverifiable for that) |
| Flag | Meaning |
|---|---|
--all | Drop the worker/* prefix filter |
--force | Actually delete (otherwise dry run) |
--yes | Confirms the --all --force scope; nothing else |
--allow-unverifiable | With --force, also delete unverifiable-dirty entries |
How each class is removed:
healthy— only viagit worktree remove(no--force), thengit worktree prune, thengit branch -d(never-D). If git declines, the entry fails; it is never escalated to a filesystem delete.unregistered-dir/registered-prunable—git worktree removeis tried first. Their dirty state usually cannot be probed (dirty: probe unavailable), so a directory holding anything beyond git metadata is refused asunverifiable-dirtyand kept unless you pass--force --allow-unverifiable. Empty or git-metadata-only directories are deleted from disk (rm -rf) when git declines, then pruned andgit branch -dis attempted. The content, self and live-owner checks are repeated at the moment of deletion, andrm -rfonly ever targets a real directory (never a symlink/junction) whose real path is inside the worktrees root.missing-dir— only pruned.
git branch -d still refuses to delete an unmerged branch. A forced run reports Removed N worktree(s). Refused N, failed N.
/worktree status
Counts per class plus guarded entries:
Worktree Status:
Git version: 2.43.0
Worker worktrees: 2
Compete worktrees: 1
Healthy: 2
Registered prunable: 0
Unregistered dirs: 1
Missing dirs: 0
Nested worktrees: 0
Refused (guarded): 2
Directory: D:\repo\.worktreesConfiguration
Worktree settings in your fleet config (~/.fleet/config.json or project .fleet/config.json):
worktrees?: {
enabled?: boolean; // default: true
directory?: string; // default: '.worktrees'
symlinkNodeModules?: boolean; // default: false
}Options
| Option | Default | Description |
|---|---|---|
enabled | true | Enable/disable worktree isolation for shell-capable and unrestricted-write workers |
directory | '.worktrees' | Directory name for worktrees (relative to repo root) |
symlinkNodeModules | false | Symlink node_modules from main repo into worktrees to save disk space and install time |
Workers, the startup reaper and the /worktree commands all honour a custom directory.
Disabling Worktrees
{
"worktrees": {
"enabled": false
}
}When disabled, all workers (including coders) share the main working directory. This is simpler but risks file conflicts during parallel editing.
/compete Integration
The /compete command uses the WorktreeManager with the prefix compete:
/compete "Implement caching layer"This creates 3 parallel worktrees:
.worktrees/compete-pragmatic/ → branch: compete/pragmatic
.worktrees/compete-clean/ → branch: compete/clean
.worktrees/compete-alternative/ → branch: compete/alternativeEach implementation runs in full isolation. After judging, the winning implementation is merged:
/compete pick pragmatic
→ Merges compete/pragmatic into main branch
→ Cleans up all compete worktrees and branchesCompete Lifecycle
/compete <description>
→ Create 3 worktrees (pragmatic, clean, alternative)
→ Spawn 3 coder workers in parallel
→ Workers implement independently
/compete judge
→ Spawn reviewers to rate each implementation
→ Scores: correctness, quality, testing, performance, simplicity, integration
/compete pick <name>
→ Merge winning branch
→ Remove all compete worktrees
/compete cleanup
→ Remove compete worktrees that pass every refusal guard
(e.g. non-empty dirs whose dirty state can't be verified are kept)Startup Checks
Git Version Requirement
Worktrees require Git ≥ 2.15. On startup, the CLI checks your Git version:
- ≥ 2.15 → worktrees enabled normally
- < 2.15 → warning displayed, worktrees disabled for the session
⚠️ Git 2.12.0 detected — worktrees require git ≥ 2.15. Disabling worktree isolation.If git is not found at all, the CLI prints ⚠️ Git not found — worktree isolation disabled.
Orphan Worktree Detection
On startup, the CLI classifies existing worktrees and warns about leftovers from previous sessions:
⚠️ 2 orphaned worktree(s) found (1 unregistered, 1 prunable, 0 missing). Use /worktree cleanup to review.nested worktrees are never orphans and are not counted, so the total always equals the sum of the breakdown.
Automatic Reclamation (WorktreeReaper)
Failed, killed or conflicted workers deliberately keep their worktree for inspection. To bound growth, a best-effort startup reaper (since v0.59.0) reclaims worker worktrees (and directories with no resolvable branch) older than the retention window that pass every refusal guard above. It removes at most 25 entries per startup (a larger backlog drains across restarts), never throws, and logs a one-line summary such as worktree reaper: reclaimed 3, retained 2 (unmerged=2). Non-empty directories whose dirty state cannot be verified are always retained (unverifiable-dirty) — the reaper never deletes them. missing-dir records are pruned regardless of age.
| Env var | Default | Description |
|---|---|---|
AGENTS_FLEET_WORKTREE_REAP | on | 0/false/no/off disables the startup reaper |
AGENTS_FLEET_WORKTREE_REAP_RETENTION_DAYS | 7 | Age (directory mtime) before a preserved worktree becomes reclaimable. 0 disables the age filter (every guarded-safe entry is eligible) |
Best Practices
- Review with
/worktree cleanup, then delete with/worktree cleanup --force— the plain command is a dry run; refused entries (dirty, unmerged, locked,self, …) are never deleted. Add--allow-unverifiableonly after inspecting anyunverifiable-dirtyentries - Check
/worktree statusif things seem wrong — confirms Git version and per-class counts - Use
symlinkNodeModules: truefor Node.js projects — avoids duplicatenode_modulesin each worktree - Don't manually edit files in
.worktrees/— these directories are managed by the CLI - Add
.worktrees/to.gitignore— worktrees should not be committed