Skip to content

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 branch worker/{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:

  1. Create — git worktree add .worktrees/{agentId} -b worker/{agentId}
  2. Remove — git worktree remove .worktrees/{agentId} + branch cleanup
  3. Merge — fast-forward or three-way merge back to the source branch
  4. 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 user

SDK 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 block

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

ClassMeaning
unregistered-dirDirectory exists under the worktrees root but git has no record of it
missing-dirGit has a record but the directory is gone — remedied with git worktree prune, never rm
registered-prunableGit itself reports the entry as prunable
nestedRegistered and present, but not a direct child of the worktrees root (it lives inside another worktree). Never an orphan, never removed automatically
healthyRegistered, 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:

RefusalWhy
main-checkoutThe path is the repository's main checkout
lockedgit worktree lock is set
dirtygit status --porcelain reports uncommitted files
unmergedThe branch/HEAD has commits not reachable from the base branch
live-agentA 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
nestedThe entry is a nested worktree
contains-worktreesOther registered worktrees live beneath this path; deleting it would orphan them
selfThe 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-dirtyThe 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-unmergedThe 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
symlinkThe 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.
InvocationEffect
/worktree cleanupPreview worker/* worktrees (plus directories with no resolvable branch); nothing deleted
/worktree cleanup --forceDelete the reclaimable worker/* entries; refused entries (including unverifiable-dirty) are kept
/worktree cleanup --force --allow-unverifiableAs 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 --allPreview all worktrees, including compete/* and hand-named trees
/worktree cleanup --all --force --yesDelete 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)
FlagMeaning
--allDrop the worker/* prefix filter
--forceActually delete (otherwise dry run)
--yesConfirms the --all --force scope; nothing else
--allow-unverifiableWith --force, also delete unverifiable-dirty entries

How each class is removed:

  • healthy — only via git worktree remove (no --force), then git worktree prune, then git branch -d (never -D). If git declines, the entry fails; it is never escalated to a filesystem delete.
  • unregistered-dir / registered-prunable — git worktree remove is tried first. Their dirty state usually cannot be probed (dirty: probe unavailable), so a directory holding anything beyond git metadata is refused as unverifiable-dirty and kept unless you pass --force --allow-unverifiable. Empty or git-metadata-only directories are deleted from disk (rm -rf) when git declines, then pruned and git branch -d is attempted. The content, self and live-owner checks are repeated at the moment of deletion, and rm -rf only 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\.worktrees

Configuration ​

Worktree settings in your fleet config (~/.fleet/config.json or project .fleet/config.json):

typescript
worktrees?: {
  enabled?: boolean;           // default: true
  directory?: string;          // default: '.worktrees'
  symlinkNodeModules?: boolean; // default: false
}

Options ​

OptionDefaultDescription
enabledtrueEnable/disable worktree isolation for shell-capable and unrestricted-write workers
directory'.worktrees'Directory name for worktrees (relative to repo root)
symlinkNodeModulesfalseSymlink 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 ​

json
{
  "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/alternative

Each 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 branches

Compete 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 varDefaultDescription
AGENTS_FLEET_WORKTREE_REAPon0/false/no/off disables the startup reaper
AGENTS_FLEET_WORKTREE_REAP_RETENTION_DAYS7Age (directory mtime) before a preserved worktree becomes reclaimable. 0 disables the age filter (every guarded-safe entry is eligible)

Best Practices ​

  1. 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-unverifiable only after inspecting any unverifiable-dirty entries
  2. Check /worktree status if things seem wrong — confirms Git version and per-class counts
  3. Use symlinkNodeModules: true for Node.js projects — avoids duplicate node_modules in each worktree
  4. Don't manually edit files in .worktrees/ — these directories are managed by the CLI
  5. Add .worktrees/ to .gitignore — worktrees should not be committed