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.

Carriers retire by purpose

Temporary carriers
Engineer, reviewer, QA, plan, scratch and verification checkouts - harness .claude/worktrees/* or conductor-created .agentic/worktrees/*. Owner: the conductor that spawned or created it.

Trigger: completion - the review gates on its output have returned, or the work is abandoned, and its agent gets no further work.
Integration and runtime carriers
.agentic/worktrees/${FEATURE_BRANCH}, a QA dev-server checkout, evidence a later step reads. Owner: the conductor.

Trigger: the same completion event. Ticket close and PR merge are never the trigger.
Purpose, not path, sets owner and trigger. Path classification (bin/tests/worktree_model.py's classify_entry, never branch name) drives only the evidence-gated sweep of leftovers.

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: the carrier's completion. Phase 8 runs it right after the engineer's push.

ds-retire-carrier <worktree-path>
# exit 0: checkout removed, branch kept
# exit 3: hold recorded (owner, reason, revisit event)
ds-retire-carrier --list-holds        # open holds, at /ds-wrap Step 5 and session start
  • Removes the checkout only when unlocked, clean, free of protected ignored content (.agentic/, .env*) and nested worktrees, and every commit it pins keeps a ref
  • Never forces, unlocks, prunes, pushes or deletes a branch
  • A checkout with changes is held, never force-removed
An unpinned commit is released by pushing or merging it, or by --pin, which keeps it on a local carrier-pin/* branch.

Cleanup: feature worktrees

Integration and feature checkouts retire at the same completion event, through the same command.

ds-retire-carrier .agentic/worktrees/<branch-name>
# explicit hold for what the tool cannot see (a live server, a pending consumer):
ds-retire-carrier --hold --reason "<why>" --revisit "<event>" <path>
ds-retire-carrier --release <path>   # only after the revisit event
  • Branch deletion is a separate step with its own proof (ancestor of the live origin tip or of $FEATURE_BRANCH, or bin/ds-branch-prune)
  • gh pr merge <number> --squash --delete-branch is the merge protocol, not a cleanup step

Between tasks, no completed carrier stays without a recorded hold.

One hold per path: a blanket "preserve every worktree" instruction is not a hold.

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 unlocked, not-self, idle past the activity window (default 3h), clean, 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. DS-245: --min-age-hours is explicit-supply only, off unless passed, and every unattended caller ends up with it - the session-start reap pins it at the invocation, --archive-unproven implies it, and /ds-wrap Step 5 gets it from /ds-cleanup-worktrees Step 2, which applies it whenever a wrap lock is held
  • .agentic/** INVERTS the polarity: protected by default, disposable only for a named set of machine-written state (telemetry, hook counters/sentinels, the context.md rollup and context.d/, session-log/, wrap/, cache dirs - list and drift test in bin/ds-cleanup-worktrees) - 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
  • Retire via ds-retire-carrier <path> (--pin keeps an unpushed tip) or record a hold - never --force; a hold naming uncommitted changes means content log -1 couldn't show - inspect status --porcelain and diff first
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.

Isolated. Pruned. Clean.

Every spawn contained. Every merge leaves no trace.

github.com/Space-Dinosaurs/DinoStack