Worktree Lifecycle

Isolation by default. Clean up by rule.

Why worktree isolation matters

  • The conductor's main worktree holds untracked scaffolding: .agentic/, loop-state files - NOT in-flight planning artifacts, which are committed and pushed as soon as they are authored
  • A subagent running in the same tree can stage and commit conductor files it was never meant to ship
  • This does not surface as a test break - it surfaces as a reviewer asking "why is .agentic/loop-state.json in this PR?" days later
  • Worktree isolation prevents both pollution and cross-engineer commit contamination when parallel spawns share a tree
  • Every engineer, qa-engineer, and release-orchestrator spawn MUST use isolation: "worktree"
The conductor never edits the shippable tree directly - not even for Trivial one-line changes. Only the execution location moves off the primary checkout.

Two classes of worktree

Isolation worktrees
Path .claude/worktrees/*. Created automatically by the Agent tool when isolation: "worktree" is set on the spawn call. Each parallel subagent gets its own copy of the tree.

Cleanup trigger: once the agent returns and the conductor opens a PR (or confirms no PR is needed), the isolation worktree is redundant. The branch holds the commits. Remove immediately.
Feature worktrees
Path .agentic/worktrees/<branch-name>.

Cleanup trigger: removed after the PR is merged. The merge (not the PR open) is the trigger.
Classified by path, never branch name (bin/tests/worktree_model.py's classify_entry is normative - DS-118: a renamed branch can live inside either admin directory, so a name-based scheme collides). Two classes, two distinct cleanup triggers. Getting the trigger wrong leaves stale worktrees that accumulate between runs and confuse subsequent sessions.

The isolation mandate

There is no in-place exception. The mandate applies to every shippable-edit spawn:

  • Elevated-risk engineer spawns require isolation (standard case)
  • Trivial-path solo engineer spawns also require isolation - the lightweight posture (no Skeptic, no brief) is preserved; only the execution location changes
  • qa-engineer spawns require isolation
  • release-orchestrator spawns require isolation

The Agent tool creates isolation worktrees automatically when isolation: "worktree" is set on the spawn call. No manual git worktree add is needed or correct for isolation worktrees - that command is for manually-managed feature/subagent worktrees only.

The version floor matters: on Claude Code builds predating the isolated-worktree own-file fix, an isolated engineer self-denies on its own files and deadlocks. The fix is a hard floor for the delegation model.

"Conductor never edits the shippable tree directly" is not a convention. It is the mechanism that prevents scaffolding files from appearing in PRs.

Cleanup: isolation worktrees

Trigger: agent returned output AND conductor has opened a PR (or confirmed no PR needed).

# Verify no uncommitted changes before removing:
git -C <worktree-path> status --porcelain
# If clean (no output), remove the worktree and local branch:
git worktree remove <worktree-path>
git branch -D <branch-name> 2>/dev/null || true
# Safe: the PR is backed by the branch on origin, not this local ref.
# Only the redundant local branch is removed; pushed commits and PR are unaffected.
# If modified tracked files exist, inspect first then force-remove:
# git worktree remove --force <worktree-path>
  • The local branch lingers after worktree remove without an explicit branch -D
  • Force-remove is only safe after confirming nothing important is uncommitted
  • Isolation worktrees with changes persist until the conductor explicitly removes them
Isolation worktrees with no changes are auto-cleaned by the Agent tool. Those with changes are the conductor's responsibility.

Cleanup: feature worktrees

Trigger: the PR is merged (not when the PR is opened).

gh pr merge <number> --squash --delete-branch
git worktree remove --force <worktree-path>
git branch -D <branch-name>   # if not auto-deleted by --delete-branch
git worktree prune             # clean up any stale metadata
  • --delete-branch on gh pr merge may not auto-delete in all gh CLI versions; the explicit git branch -D is the fallback
  • git worktree prune cleans up stale metadata left over from worktrees removed without the normal command

Do not leave stale worktrees between tasks. Between tasks there should be no active subagent worktrees.

Feature worktrees outlive the PR open state; isolation worktrees do not. That asymmetry is the main source of incorrect cleanup timing.

Session-start prune

Run once at session start in the conductor preflight - not before every subagent spawn. Cache the resolved base branch for the session:

git fetch origin
git worktree prune
# Local branch prune - bin/ds-branch-prune (DS-153), covering
# worktree-agent-* branches and every other stale local branch:
command -v ds-branch-prune >/dev/null 2>&1 && ds-branch-prune

The branch prune (bin/ds-branch-prune) runs alongside it - a four-layer, first-match-wins subsumption predicate, never force-deletes unproven work. It resolves its own base branch (shared resolve_base_branch, bin/_lib.py) rather than assuming origin/main, and deletes nothing when that resolution fails:

  1. Ancestry - every commit is literally on the resolved base
  2. Squash-patch equivalence - the branch's cumulative delta matches a merged PR's squash commit
  3. Tip-subsumption - the tip carries no commit beyond the head that was squashed
  4. Content-on-main - every file the branch touched is byte-identical to the resolved base
  • Re-run the preflight only if the user explicitly switches branches or after 30+ minutes of idle time
  • Absence of proof is always a skip (SKIP_UNPROVEN) - a bare "a PR merged" signal is never sufficient on its own
  • DS-196: the preflight also invokes a BACKGROUNDED ds-cleanup-worktrees reap (after git worktree prune, before branch prune) - the foreground shell returns immediately; output appends to .agentic/worktree-reap.log with a per-run header. Suppress with AE_WORKTREE_REAP_DISABLE=1; the 30-min-idle re-fire applies here too and is safe by construction (every gate re-checks fresh state)
The aggressive per-session prune is a complement to Claude Code's own 30-day orphan sweep, not a replacement. Stale worktrees accumulate between sweeps.

Ad-hoc cleanup obligation + the automatic reaper

/ds-implement-ticket Phase 8's own cleanup only fires on that command's own success path. Any ad-hoc isolation: "worktree" spawn outside it is on the conductor: clean it up at the natural completion point, not "eventually."

bin/ds-cleanup-worktrees is the executable form of /ds-cleanup-worktrees's predicate - it delegates the locked/dirty/branch-evidence decision to worktree_model.disposition_for (the same normative function the command file cites), never a second copy of that logic:

  • Removable only when clean, unlocked, not-self, past an age floor (default 24h), free of PROTECTED gitignored content (docs/planning/**, .env*, *.local block by default; everything else ignored, including generated adapter output, is disposable - --strict-ignored for the old fail-safe-allowlist polarity), AND the branch is MERGED/an ancestor of base, or unpushed with zero unique commits - a CLOSED PR or an unpushed branch WITH unique commits is always reported, never removed
  • .agentic/** INVERTS the polarity: protected by default, disposable only for a small named set (events.jsonl/telemetry, wrap/, codex-prompt-generation/, hud/, cache dirs) - round 3's blanket protection measured removed=0 here since this repo dogfoods itself and every worktree accumulates telemetry; .agentic/events.jsonl is salvaged into the primary repo before removal, and a failed salvage blocks removal rather than risking a silent loss
  • --count-only is the mode both passive triggers use automatically - ds-base-sync's post-merge advisory note and a SessionStart nudge past a small worktree-count threshold - a single git worktree list call, no network, no per-entry evaluation
  • Neither passive trigger ever removes anything - actual removal stays an explicit /ds-cleanup-worktrees or a bare ds-cleanup-worktrees (no flags) invocation; --dry-run computes and reports without removing anything, and --dry-run --explain (not --explain alone) is the dry-run report form
  • ds-cleanup-worktrees --multi-repo sweeps SEVERAL repos in one invocation, in-process - discovers repos via explicit --repo xN, positional root-directory scan, or a ~/.agentic/cleanup-worktrees.json fallback (--init-config scaffolds that fallback file for a first-time setup, never overwriting one that already exists), each repo resolving its own base independently; --max-repos N caps the discovered-and-deduped list to the first N, bounding git-call cost, not just display; --multi-repo --report (optionally --count-only for a cheap fast tier, or --json) is read-only and ranks repos worst-first - "which project is worst" - the recommended first look before a multi-repo removal run
  • DS-196: these --count-only passive nudges stay report-only, unchanged. A SEPARATE, genuinely mutating invocation is also invoked, backgrounded, from the session-start prune block itself (see previous slide) - the two are not the same mechanism
The passive nudges are report-only. The session-start reap is the mutating backstop - suppress it with AE_WORKTREE_REAP_DISABLE=1 if needed.

The unproven class: archive, don't accumulate

Even a worktree that passes every gate can still be stuck SKIP_UNPROVEN: a real, unmerged, never-pushed branch with no matching PR - disposition_for correctly refuses to guess. A branch that is genuinely unpushed with no PR still never resolves this way. DS-196 qualifies this for the pushed case: a SKIP_UNPROVEN branch that has since been pushed to origin and reached a resolved (non-open) PR state can resolve via the new origin_reachable evidence source instead (LENIENT-only, evaluated after pr_state) - the STRICT branch-deletion path is untouched.

A 2026-08-11 manual one-off operator sweep already solved this for BRANCHES: archive into a verified git bundle, prove the restore path, then delete (.agentic/branch-archive/, DS-153; bin/ds-branch-prune itself does not call git bundle). ds-cleanup-worktrees --archive-unproven - OPT-IN, never the default - extends that exact pattern to WORKTREES:

  • Only two dispositions qualify - SKIP_NOT_PUSHED and SKIP_AMBIGUOUS_NO_PR - an explicit whitelist, never the whole SKIP_UNPROVEN bucket: SKIP_PR_OPEN (a hard safety override) and SKIP_LS_REMOTE_ERROR (a transient failure) are NEVER archived, even with the flag set
  • Refuses to run at all in degraded gh mode (--no-gh, or gh unavailable/unauthenticated) - without PR evidence it can't tell a genuinely-unprovable branch from one behind an open PR
  • git bundle create captures the FULL branch (every commit unique to it), then git bundle verify runs BEFORE any removal - a failed create or verify blocks removal entirely, same discipline as the telemetry-salvage guard
  • Compact by default (DS-191): objects already reachable from the resolved base are excluded when doing so still yields a non-empty bundle - a small bundle plus a recorded prerequisite commit, the actual full-disk reclaim path this exists for. Falls back to full-history (with a NOTE: on stderr) for any of: base ref unverifiable, no shared history with the branch, explicit --base mismatching auto-resolution, a concurrent-fetch TOCTOU race, or an unparseable rev-list count
  • Removes the WORKTREE only, never the branch - bin/ds-branch-prune still owns branch deletion
  • Prints the exact (braced) restore command: git fetch <bundle> "refs/heads/${BRANCH}:refs/heads/${BRANCH}" - a compact bundle's restore additionally requires the recorded prerequisite commit (the fork-point with the base, NOT necessarily the base's own tip) to still be present locally
  • The explicit---base mismatch case (above) exists because an unvalidated caller-supplied ref could otherwise be deleted later, permanently orphaning the bundle's prerequisite
  • .agentic/worktree-archive/ is gitignored and grows unbounded - pruning it is the operator's job, same as .agentic/branch-archive/
Without --archive-unproven, SKIP_UNPROVEN worktrees are reported and never touched - unchanged default behavior.

Implicit Trivial batching: open the PR at first push

A series of individually-Trivial changes to the same surface can share one draft PR instead of one PR per tweak:

  • The first tweak commits, pushes, and opens a draft PR immediately - CI runs on every push, nothing is deferred
  • Every continuation detaches the engineer's OWN harness isolation worktree onto the batch's fetched remote tip (git checkout --detach origin/chore/tweak-<key>), gated by a mandatory git status --porcelain empty-check first (a fresh worktree is clean by construction - free on the normal path, catches a stray rider file otherwise) - no nested worktree, no separate directory; every edit lands inside caller_root by construction, so the write/read isolation hooks admit it trivially
  • A detached worktree's bare branch-name push either fails loudly (no local branch) or silently pushes the wrong ref (a stale local branch) - the explicit HEAD:refs/heads/chore/tweak-<key> refspec form is mandatory in both cases
  • After the push lands, the engineer re-attaches (git checkout worktree-agent-<id>) - resolve_branch_worktree intentionally never matches a detached HEAD, so skipping this silently defeats the harness's own cleanup lookup
  • Draft state mechanically blocks merge (both plain and --admin forms) until gh pr ready - verified live against this repo (PR #815)
  • Two concurrency fixes: a session-scoped <key> token so two sessions minting for the same file can never converge, and an origin-visible tweak-claim: PR comment so two sessions continuing the same batch don't collide
  • Ship triggers: explicit ship-language, a new non-Trivial request (async - the batch ships while the new spawn starts immediately), an end-of-session signal, or next-session rediscovery (a persisted draft PR is never silently lost)
Canonical mechanism: content/references/worktree-lifecycle.md §Implicit Trivial batching: open the PR at first push. gh unavailable or under-scoped degrades gracefully - never blocks, never silently unclaims a continuation.

SKIP_UNREFERENCED_COMMIT: a distinct crash residual

A crashed continuation leaves ONE artifact - its own harness isolation worktree, detached, possibly with an unpushed commit. No nested worktree, no orphaned second entry.

  • head_reachable (the fact that would let a pushed-already leftover auto-sweep) is dead code in bin/ds-cleanup-worktrees - hardcoded "not_checked" at every construction site - so every detached leftover, pushed or not, resolves SKIP_UNREFERENCED_COMMIT today, refused by design, same work-preserving discipline as SKIP_UNPROVEN
  • Triage manually: git -C <path> branch -r --contains "$(git -C <path> rev-parse HEAD)" - nonempty means the work already reached origin and is safe; empty means this worktree is the sole copy
  • Recovery: inspect the tip (git -C <path> log -1), then push it to its intended branch or cherry-pick it where it belongs
  • Discard via plain git worktree remove <path> first - a refusal naming uncommitted files means there's working-tree content log -1 couldn't show; inspect status --porcelain and diff before deciding, only then --force
This IS the harness's own locked isolation worktree - a lock refusal is a DIFFERENT case from an uncommitted-content refusal; never unlock/force a still-locked one, let the session-start prune or reap resolve it once the lock releases.

Isolated. Pruned. Clean.

Every spawn contained. Every merge leaves no trace.

github.com/Space-Dinosaurs/DinoStack