Worktree and evidence recovery
Recreate a feature’s worktree from its branch and backfill the run state that went with it, without letting the build redo work that is already committed. For operators who removed, moved, or lost .worktrees/<slug>.
Not sure this is the right runbook? Run
/daemon-triage— it gathers the evidence read-only, classifies the failure, and routes to the one runbook that owns it.
The branch is the source of truth. A worktree checkout is disposable. Everything in .worktrees/<slug> except the git checkout itself can be rebuilt; the commits on feat/daemon-<slug> cannot.
When a later authorized cleanup removes the recreated worktree, the daemon runs the project’s optional bin/teardown first. Write that hook to tolerate missing .pipeline/ and .env: it receives CI=true and a WORKTREE_NAMESPACE derived from the worktree path, not persisted recovery state. A hook failure or timeout is logged and contained, so it does not replace the cleanup safety proofs in this runbook. See project teardown hook.
Symptom
One or more of:
.worktrees/<slug>is gone, butfeat/daemon-<slug>still has commits on it.git worktree list --porcelainshows the path with aprunableline.git worktree addfails:fatal: '.worktrees/<slug>' is a missing but already registered worktree; use 'add -f' to override, or 'prune' or 'remove' to clear(exit 128).- The daemon re-dispatches a feature that was most of the way through BUILD and starts over at task 1.
- A build reports
no_task_progressor “N/M tasks pending/not completed” for tasks whose commits are visibly on the branch. ai-conductor inlinerefuses withOrphaned conductor state in <path>.- The daemon repeatedly logs
[session-hooks] restored <script> in <worktree>for one feature, or reports that it could not restore an enforcement script. - A step fails with
Cannot dispatch '<step>': its working directory <path> does not exist.The engine’s dispatch preflight refused to launch a provider into a path that is gone, and halted the run instead of retrying into it. Nothing was written back into the missing path — recreating.pipeline/there would leave a stub that makes the nextgit worktree addfail 128. If the worktree disappeared mid-run, treat that as premature cleanup unless a shipped record is already proven on the default branch. Check the branch before recovering: a feature whose ship is already recorded needs shipment reconciliation, while any other feature needs its worktree and evidence recovered.
Why losing the directory costs you
.pipeline/ lives inside the worktree and is gitignored wholesale — it is never in a commit and never recoverable from git. Deleting .worktrees/<slug> deletes all of it:
| Lost file | What it held | Recovers how |
|---|---|---|
.pipeline/conduct-state.json | Per-step status, tier, track, session start, PR url | Re-seeded by the daemon with DECIDE done and everything else pending; BUILD/SHIP progress is lost |
.pipeline/task-status.json | One row per plan task | Re-seeded from the plan; tasks with a Task: <id> trailer on a commit already on the branch come back completed, the rest pending |
.pipeline/task-evidence.json | Evidence stamps, no-evidence counters | Not consulted to restore rows; counters reset |
.pipeline/engine-state.json | activePlanPath and any admitted repair obligations | Only the plan pointer falls back to stem match, then to a single plan on disk; repair obligations are not reconstructed |
.pipeline/gates/*.json | Durable gate verdicts | Recomputed from on-disk evidence |
.pipeline/events.jsonl | The run timeline --report reads | Gone |
SHIP-tail evidence (build-review.json, test-suite-evidence.json, acceptance-specs-red.json, prd-audit.md, …) | Verdicts for the SHIP gates | Regenerated by re-running the step |
The evidence sidecar is deliberately not consulted to restore rows. What keeps already-finished tasks recognized is Task: <id> trailers on the commits: when any plan task has no row in task-status.json (the file is missing, empty, unparseable, or partially written), the re-seed reads those trailers and restores each proven missing task as completed with its commit sha and restored_from: "task-trailer". Missing tasks with no trailered commit come back pending, as do untrailered in_progress rows at the next BUILD dispatch, which is correct — nothing on the branch proves they were done. Commits committed without a Task: trailer are still invisible and must be restored by hand — see Recovery step 5.
Diagnosis
1. Confirm the branch still holds the work
git log --oneline feat/daemon-<slug> | head -20
If the branch is gone too, the work is gone. Check the reflog and any pushed remote copy before concluding that:
git reflog show feat/daemon-<slug>
git ls-remote --heads origin "feat/daemon-<slug>"
2. Check the worktree registry
git worktree list --porcelain
A prunable gitdir file points to non-existent location line under the worktree path means git still counts it as registered even though the directory is gone. That stale registration is what makes the engine’s reconcile step report the worktree “reused” and hand the build a path that does not exist, and it is what makes every git worktree add for that path exit 128.
3. Check which directory convention is in play
<repo>/.worktrees/ is the only worktree creation target. .claude/worktrees/ is read-only legacy: the resume probe still looks there, and the self-host live boundary excludes it, but no engine code ever creates anything under it. Never recreate a worktree there — the daemon will not find it, and it will not be provisioned.
4. Ask the engine what it thinks is missing
ai-conductor inline --diagnose "<feature description>"
When the recorded state says the feature is past the worktree step but no worktree exists at any conventional location, this exits 1 with Orphaned conductor state in <path>. and lists the locations it searched. That message is the confirmation that state and disk disagree.
5. Classify session-hook repair messages
ai-conductor daemon logs --lines 200 | grep '\[session-hooks\]'
One restored line is a successful build-preflight repair; leave the feature running. Do not park it or recreate .pipeline/session-hooks/ by hand. Repeated repairs for the same feature indicate that .pipeline is being removed or replaced; investigate that state-loss path, including the related #549 and PR #770, rather than treating the hook as the defect. A could not restore result still blocks the build: park before changing the worktree, correct the filesystem cause, then let the next build preflight provision the scripts.
Recovery
1. Park the feature before touching anything
ai-conductor daemon park <slug>
What it changes: writes .daemon/parked/<slug> at the main repo root. Without this, the daemon re-dispatches into the half-repaired state on its next poll. Confirm: the command prints Parked '<slug>' — it will not be dispatched or re-kicked until unparked. Full ordering rules are in emergency stop a running feature.
2. Clear the stale registration
git worktree prune
Blast radius: removes bookkeeping only, and only for worktrees whose directories no longer exist. It never deletes a branch, never touches a worktree that is still present, and never touches commits.
Confirm: git worktree list --porcelain no longer lists the missing path.
3. Recreate the worktree from the branch
git worktree add .worktrees/<slug> feat/daemon-<slug>
What it changes: creates .worktrees/<slug> checked out at the existing branch tip. This is the same “attach to an existing branch” case the engine itself uses when a branch survived its worktree, so the result is byte-identical to what the daemon would produce.
Confirm:
git -C .worktrees/<slug> rev-parse --abbrev-ref HEAD # → feat/daemon-<slug>
git -C .worktrees/<slug> log --oneline | head -5
Do not use git worktree add -b here. That cuts a new branch from the base and would abandon every commit the build already made.
4. Restore the plan pointer
cat > .worktrees/<slug>/.pipeline/engine-state.json <<'JSON'
{
"activePlanPath": ".docs/plans/<slug>.md"
}
JSON
Create .worktrees/<slug>/.pipeline/ first if it does not exist. What it changes: it is the first, authoritative input to plan resolution. Without it the engine falls back to matching the plan stem, then to “the only plan on disk”; with several plans present and no match, resolution returns nothing and the build gate fails closed rather than guessing.
Confirm: the file parses and the referenced plan exists:
test -f .docs/plans/<slug>.md && echo plan-ok
5. Make already-finished tasks visible again
This is the step that stops the build from redoing finished work.
The build completion gate and the stall breaker both resolve a task id from the union of two sources: rows marked completed/skipped in .pipeline/task-status.json, and Task: <id> trailers on commits in merge-base(origin/<base-branch>, HEAD)..HEAD.
First, see which commits are self-describing:
git -C .worktrees/<slug> log origin/<base-branch>..HEAD \
--format='%h %s%n Task: %(trailers:key=Task,valueonly)'
- Commits that carry a
Task:trailer need no action. Their task ids resolve straight from git, and a reconstruction of a missing/emptytask-status.jsonwrites them back ascompletedrows automatically (look for[task-seed] restored <id> as completed from Task: trailer on <sha>in the daemon log). - Commits with a blank
Task:line are invisible to the gate. Those task ids must be restored by hand.
For the invisible ones, write .worktrees/<slug>/.pipeline/task-status.json:
{
"plan_ref": ".docs/plans/<slug>.md",
"tasks": [
{ "id": "1", "name": "<task name from the plan>", "status": "completed" },
{ "id": "2", "name": "<task name from the plan>", "status": "pending" }
]
}
What it changes: the routing input the build gate reads. Task ids and names come from the plan’s task headings — ### Task <id> — <title>, ### Task <id>, or the bare ### T<n> shorthand. Rows already marked completed or skipped are preserved verbatim across every re-seed, so this edit survives the automatic re-seed that happens at build entry; every other row is reset to pending and new plan tasks are added as pending.
Confirm: the file is valid JSON and the ids match the plan:
node -e "JSON.parse(require('fs').readFileSync('.worktrees/<slug>/.pipeline/task-status.json','utf8'))" && echo json-ok
grep -nE '^#{1,6} +(Task +[A-Za-z0-9._,-]|T[0-9])' .docs/plans/<slug>.md
ai-conductor task done <id> is not a substitute: it only clears the .pipeline/current-task stamp and never modifies task-status.json.
6. Decide what to do about conduct-state.json
When the daemon next dispatches this slug it reads .pipeline/conduct-state.json from the worktree. If the file is absent it writes a fresh one that stamps the DECIDE steps (worktree, memory, explore, prd, complexity, stories, conflict_check, plan, architecture_diagram, architecture_review) as done and leaves BUILD and SHIP unset, so the run restarts at the first BUILD step. For a feature that had reached SHIP, that means re-running the whole BUILD phase’s gates.
To preserve recorded progress, write the step statuses back yourself:
{
"feature_desc": "<slug>",
"complexity_tier": "M",
"track": "product",
"worktree_branch": "feat/daemon-<slug>",
"acceptance_specs": "done",
"build": "done",
"build_review": "pending"
}
Valid statuses are pending, in_progress, done, failed, refused, skipped, stale. A refused status means an entry or human-judgment condition stopped the step before its own work was judged failed; clear its HALT markers and re-run rather than rewriting the state by hand. Only claim done for a step whose evidence you can point at — the gates re-verify from disk anyway, and a done you cannot justify just moves the failure later.
Never leave the file empty. A zero-byte conduct-state.json is a hard error (State file is empty), not an empty state. If you want a clean slate, write {} or delete the file.
Confirm:
cd .worktrees/<slug> && ai-conductor inline --status
That prints ## Conductor State and the parsed JSON without starting a run.
7. Let the daemon re-provision the rest
You do not need to recreate the worktree’s hooks, .env, or .claude/settings.local.json by hand. On the next dispatch the daemon re-runs worktree preparation, which writes WORKTREE_NAMESPACE into the worktree .env, installs the per-worktree git hooks under .pipeline/git-hooks/, writes and wires the session hooks, excludes engine artifacts, and runs the project’s bin/setup if it has one. All of those steps are idempotent.
8. Unpark last
ai-conductor daemon unpark <slug>
What it changes: resets the no-evidence attempt counter in the recreated worktree, then removes .daemon/parked/<slug>. Do this only after steps 2–6 are verified — unparking first and repairing after is what produces the 128 spin described in emergency stop a running feature.
Verification
- The registry is clean — the path is listed, with no
prunableline:git worktree list --porcelain | grep -A3 "<slug>" - The worktree is on the right branch at the right commit:
git -C .worktrees/<slug> rev-parse --abbrev-ref HEAD git -C .worktrees/<slug> rev-parse HEADThe SHA must match the branch tip you recorded in Diagnosis step 1.
- State parses:
cd .worktrees/<slug> && ai-conductor inline --status - The build gate does not ask for finished tasks. After the next dispatch, read the verdict:
cat .worktrees/<slug>/.pipeline/gates/build.jsonreasonmust not list task ids whose work is already committed. If it does, return to Recovery step 5 — those commits are still invisible to the union. - The daemon resumes rather than restarts. The log line is
↻ resume <slug>, not▶ start <slug>, and the first dispatched task is not task 1.ai-conductor daemon logs --lines 40
If the build still reports no progress on committed work, the stall is a false negative — work through stalled or stuck feature. The file-by-file meaning of every .pipeline/ entry is in artifacts, and the reasoning behind proving progress with evidence rather than assertions is in the evidence model.