# Worktree salvage GC + WIP lifecycle — design

Date: 2026-08-14. PLAN_SLUG: `worktree-salvage-gc`.

## Problem

Idle agent worktrees pile up on the workstation. The existing GC
(`modules/monitor/lib/worktree_gc.py`, driven solely by
`modules/monitor/slices/bin/disk-maintain` under the disk-check timer) reaps only
worktrees that are idle AND clean AND secret-free; anything dirty is kept forever
(`keep("dirty")`). Agents abandon dirty worktrees, so the pile never shrinks. And
when work IS parked, restoring it today is manual archaeology no small model can do
safely.

Scope is GLOBAL: every repo under the configured project roots (`~/Projects` by
default), not overdeck only. Overdeck owns the machinery; agents in any project use
it. No new timers, units, crons, or daemons anywhere.

## Owner-approved policy

One idle threshold: **3 days** (was 7). A worktree idle ≥3 days with no live
session attached is abandoned; the sweep gets ALL its local data to safety, then
deletes the tree:

- **Attached to a live session** (see session oracle below) → kept indefinitely,
  regardless of idle time.
- **Merged into `origin/main|master` AND clean AND no precious/unknown ignored
  files** → already safe; reap after the existing **1-day** grace. Merged-but-dirty
  trees follow the salvage path like any dirty tree — merge safety covers committed
  history only.
- **Anything else idle ≥3 days** → salvage (below), verify, delete.
- **Any doubt** (git error, occupied, oracle unreadable, archive/push/verify
  failure) → keep, journal, retry next sweep. Age alone never authorizes deletion.

**The recoverability promise** (pinned, per external review): after deletion, all
local data except reproducible caches (`REPRODUCIBLE_IGNORED_DIRS`) is recoverable
— clean tracked content from git/origin, everything else from the local vault
delta archive; additionally, git-trackable non-secret work is on `origin` under
`wip/*`. Secrets never leave the machine.

## Salvage design — archive-first

External review (codex gpt-5.6-sol) rejected the earlier move-secrets-then-commit
approach: basename-pattern classification silently loses ordinary ignored files
(notes, datasets), submodule dirt, and LFS objects, and per-file moves mutate the
worktree before removal is authorized. Adopted alternative: **snapshot everything,
push a commit as a convenience layer, never mutate the tree until removal.**

### Step 1 — vault delta archive (authorizes deletion)

```
archive_worktree(worktree_path: str, vault_dir: Path, run_git: RunGit, clock: Clock) -> dict | None
```
- One `tar.zst` of the DELTA set only: every path `git status --porcelain
  --ignored=matching` reports (modified, untracked, ignored), excluding
  `REPRODUCIBLE_IGNORED_DIRS`. Clean tracked content is NEVER archived — git
  objects and the wip push already hold it provably (owner-decided after
  measurement: full-tree archives would re-store ~8.8 GB of the current 205-tree
  pile vs ~1.2 GB of genuinely-unique delta bytes; delta keeps the vault a
  rounding error instead of relocated pressure). No pattern-based guessing — the
  delta set is whatever git does not already hold.
- Built with lstat semantics: symlinks stored as symlinks, never followed; special
  files recorded, never read through. Read-only with respect to the tree.
- Failure to enumerate the delta (git error) → `None`. An empty delta returns a
  trivial success with no archive file (manifest still records the salvage).
- Transactional publish: entry dir created exclusively as `.tmp-<name>` with mode
  `0700` (umask-independent), archive file `0600`; sha256 computed; archive fsync;
  `manifest.json` (fsync) recording canonical repo path, worktree path, branch
  label or detached sha, ISO timestamp, archive sha256 + byte size, wip ref (filled
  in step 2), and the exact restore command; then atomic rename to the final entry
  name. Any failure → `None`, nothing published; stale `.tmp-*` entries are swept
  opportunistically next run.
- Entry naming (collision-proof, opaque-unique):
  `<repo-name>--<branch-label>--<YYYYMMDD-HHMMSS>--<sha7>`; exclusive-create,
  suffix on collision.
- Vault root: `~/.local/state/overdeck/worktree-vault` (`SM_WORKTREE_GC_VAULT_ROOT`
  / `--vault-root` seam, same pattern as existing options).

### Step 2 — wip push (the visible copy)

```
salvage_push(worktree_path: str, branch_label: str, run_git_env: RunGitEnv) -> dict | None
```
- Builds the commit through a **temporary index** (`GIT_INDEX_FILE` via a new
  `run_git_env` seam — `RunGit` plus an env dict; existing `run_git` callers
  unchanged). The worktree's real index, HEAD, and files are NEVER touched;
  failure needs no rollback and retries are idempotent.
- Sequence: read HEAD tree into temp index → add all → drop from the temp index
  every path whose basename matches `PRECIOUS_IGNORED_GLOBS` (they stay local, in
  the archive — never aborts, never pushes secrets) → `commit-tree` with throwaway
  identity `worktree-gc <worktree-gc@overdeck.local>`, message
  `salvage: auto-commit idle worktree <branch-label>` → push.
- Ref name: `wip/<branch-label>-<YYYYMMDD>-<sha7>` where `branch-label` is the
  short branch name (strip `refs/heads/` — the porcelain parser stores full refs),
  `detached` for detached HEAD, validated with `git check-ref-format`. The sha7
  suffix makes the name content-addressed: concurrent sweeps computing the same
  content collide harmlessly (same ref, same sha); plain push, no force — rejection
  → keep and retry.
- Verify: live `git ls-remote origin <ref>` returns exactly the pushed sha
  (`GC_GIT_PUSH_TIMEOUT_S = 60.0` for push/ls-remote).
- Nothing to commit (tree clean, HEAD already on origin) → returns a trivial
  success without pushing.

### Step 3 — removal gate

Removal proceeds only when ALL hold, re-validated immediately before
`git worktree remove` (never `rm -rf`):
- archive published (manifest fsync'd — the durable record is a precondition, not
  best-effort logging);
- wip push verified when the tree had anything not already on origin;
- `git status` output and HEAD sha identical to what was archived (any delta →
  keep, retry next sweep — closes the decide-then-mutate race);
- occupancy re-check AND session oracle re-check pass;
- existing archive-ref step (`refs/system-monitor/worktree-archive/<sha>`)
  succeeded.

After successful removal: `cleanup_local_branch` — delete the local branch iff its
exact tip sha was verified on origin in this sweep (live ls-remote evidence, not
stale `refs/remotes/*`); journal the recovery command. Then, once per repo,
`git worktree prune --expire=7.days` (serialized by disk-maintain's existing
single-flight lock; failure journaled, non-blocking).

## Session-attached protection

```
live_session_cwds() -> set[str] | None
```
- Closes the occupancy blind spot: a live-but-idle session (e.g. waiting out a
  rate limit for days) holds no process cwd inside the tree yet intends to return.
- Oracle: installed `list-sessions --json` (overdeck-owned,
  `modules/workstation/claude/bin/list-sessions`) — it already does verified
  process liveness; the sweep never re-implements ledger/pid checking. Called once
  per sweep; returns every listed session's `cwd` (all states — attached, detached,
  headless are all live processes). `None` on missing/failing/unparseable output.
- Implementer MUST verify the JSON `cwd` field is full and untruncated (the human
  table truncates); if `--json` truncates too, fix `list-sessions` first.
- Ladder: worktree equal to or ancestor of a live session cwd →
  `keep("session-attached")` indefinitely. Oracle `None` →
  `keep("session-oracle-unreadable")` for all candidates this sweep. A crashed
  session drops off the oracle; its tree becomes eligible after the normal 3 days
  — salvage + vault are the designed safety net for that case.

## Decision-ladder integration (delta to `sweep_unleased_worktrees`)

- Constants: `UNLEASED_DEFAULT_TTL_MS` 7d → **3d**; `UNLEASED_MERGED_TTL_MS` (1d)
  unchanged; new `GC_GIT_PUSH_TIMEOUT_S`, `SALVAGE_REF_PREFIX = "wip/"`,
  `VAULT_DEFAULT_TTL_MS = 60d`.
- `session-attached` check sits beside the existing occupancy check, before any
  TTL logic.
- One `git fetch --prune origin` per repo per sweep (budgeted) before any
  is-it-on-origin reasoning; fetch failure → every candidate in that repo needing
  remote proof is kept (`remote-unverified`).
- `keep("dirty")` and `keep("precious-ignored")` branches: when idle ≥ ttl and
  `apply` → salvage steps 1–3. Dry-run reports `would-salvage`, zero mutations of
  any kind.
- Clean path: HEAD not reachable from freshly-fetched origin refs → wip push
  (step 2) before removal; failure → keep.
- Idle heuristic: extend `worktree_idle_ms` to take the max over dir, `.git`,
  index, and top-level entry mtimes (cheap, bounded). Still an approximation —
  acknowledged; the session oracle and archive-first salvage carry the real
  safety, not the clock.
- Directories under `.worktrees/` on disk but absent from `git worktree list`:
  journaled `orphan-dir`, untouched (visibility only).
- Vault expiry (owner-decided): entries older than **60 days** are deleted —
  lstat-only traversal, never through symlinks, resolved target must be a direct
  child of the canonical vault root. One sweep BEFORE deletion becomes due (~7
  days ahead), the notify line lists entries about to expire — informed loss, no
  acknowledgment gate (owner: a 60-day-old WIP is practically a rewrite; the
  warning protects the secrets, not the code).

## Restore primitives — `od-wip` (global CLI)

Owner requirement: agents — especially small models — must never do git
archaeology to recover WIP. Overdeck owns the whole lifecycle: park (sweep, above),
list, restore. New CLI `od-wip` in `modules/workstation/bin/`, symlinked onto PATH
at `~/.claude/bin/od-wip` (dev-tools-live-in-overdeck convention), working in ANY
repo under the project roots.

```
od-wip list [--repo <path>] [--json]
```
- Merges three sources into one line per salvaged item: `origin` `wip/*` refs,
  local `refs/system-monitor/worktree-archive/*`, vault entries (manifest-linked
  to their wip ref where present). Shows repo, branch label, age, sizes, and the
  exact restore command. Read-only.

```
od-wip restore <wip-ref | vault-entry-id> [--repo <path>]
```
- ONE command, idempotent, dupe-proof: claims a fresh worktree via the existing
  `od-worktree` machinery on a branch created from the wip ref (or the archived
  sha), then — when a matching vault entry exists — extracts the delta archive
  over the checkout: clean tracked files come from git, the delta restores
  modified/untracked/ignored/secret files in place, byte-identical to the tree at
  salvage time. Nothing is merged, guessed, or duplicated.
- Refuses (with the reason and the existing path) if a worktree for that item
  already exists — never overwrites, never creates `-2` copies.
- Restoring from a vault-entry-id with no wip ref works from the archive alone.
- Exit codes + `--json` for programmatic use; errors name the fix, caveman-proof.

`CLAUDE.md` (repo root, worktree section) gains 3 lines: idle worktrees are
auto-salvaged to `origin` `wip/*` + local vault; recover with
`od-wip list` / `od-wip restore <ref>`; never dig manually.

## wip/* retention (owner-decided)

No automation deletes remote branches today (`finish-branch.sh` deletes local
branches only, after reachability proof) and git branches are effectively
unlimited (a ref is bytes; the only cost is listing noise). Decision: no
server-side protection, no wip reaper. Standing rule pinned here and in the
finishing/landing skill docs: **cleanup automation must never delete `origin`
`wip/*` refs.** Revisit only if listing noise ever becomes real.

## Reporting / owner visibility

- `main()` JSON: `reaped` (removed trees), `salvaged` (subset of reaped that went
  through salvage — sets overlap by definition, counts documented as such),
  `freedKib` (best-effort, labeled approximate — linked worktrees share objects),
  `vaultKib` (vault growth this sweep), `expiringSoon` (list).
- `disk-maintain` `run_worktree_gc` appends `worktree-gc:<reaped>(<salvaged>s)` to
  `cleaned`, logs full JSON (existing line).
- Edge-triggered notify: one `notify-send` line only when `reaped > 0` or
  `expiringSoon` non-empty, e.g. `worktree sweep: 2 salvaged to wip/*, 3 removed,
  4.1G freed`. Failure (no session bus on a timer) is non-fatal and logged
  distinctly. Quiet sweeps emit nothing. No new units.
- Journal (`worktree-gc.jsonl`): every step outcome, as today. Journal stays
  best-effort observability — EXCEPT the vault manifest, whose durable write gates
  removal (above).

## Error handling

Unchanged philosophy, now strengthened: deletion requires durable archive proof +
live remote verification + identity re-validation + occupancy/session re-checks.
Salvage never mutates the worktree (temp index, read-only archiving), so every
failure path is a plain `keep` with no rollback debt. Network-down sweeps keep
everything. No step ever touches the primary checkout. `git worktree remove`
remains the only tree-deletion verb, so git refuses if state changed underneath.

## Testing

Extend `modules/monitor/tests/test_worktree_gc.py` (existing fake `run_git`/clock
harness); vault/archive tests on real tmp filesystems, `run_git` faked throughout.
Minimum coverage:
- dirty idle ≥3d → archive published before any git write; secrets present in
  archive, absent from pushed tree.
- ordinary ignored files (non-secret) present in archive (finding-1 regression).
- delta boundary: clean tracked files absent from the archive; modified tracked
  files present; empty-delta tree salvages with manifest and no archive file.
- merged-but-dirty tree NOT reaped on the 1-day lane; goes through salvage at 3d.
- temp-index commit leaves real index/HEAD/status byte-identical.
- push failure / ls-remote sha mismatch / fetch failure → keep, nothing removed.
- pre-removal identity delta (status or HEAD changed after archive) → keep.
- session-attached keeps past TTL; oracle `None` keeps all; dead session absent →
  eligible.
- detached HEAD: `detached` branch label, valid ref name, no branch cleanup.
- branch-label normalization from `refs/heads/x` porcelain form; invalid names
  rejected by check-ref-format → keep.
- archive transactionality: crash before rename publishes nothing; `.tmp-*`
  cleanup; cross-device copy path verified by hash.
- symlinks archived as links; escape/traversal attempts contained.
- vault expiry: warning listed one sweep ahead; lstat-only deletion; entry outside
  canonical root never deleted; unreadable manifest kept.
- local branch deleted only on live-verified tip; recovery command journaled.
- dry-run: `would-salvage`, zero mutations.
- `od-wip`: list merges three sources; restore recreates worktree byte-identical
  (tracked + untracked + secrets); second restore refuses with pointer to
  existing; restore from archive-only entry works.
- TTL default 3d; `--ttl-ms` override honored.

Run: `python3 -m pytest modules/monitor/tests/ -q` plus the od-wip test target the
plan defines.

## Non-goals

- No new systemd units/timers/crons; no buildbox-side changes.
- No server-side branch protection (owner-decided; revisit on real noise).
- No mergeability scoring of old WIP; expiry is time-based with one warning.
- Orphan unregistered `.worktrees` dirs: journal-only.
- No UI page; observability = journal + maintenance log + notify line.

## Architecture Decisions

- Archive-first salvage adopted from external review; the earlier
  vault-move+commit design lost ignored files/submodules/LFS and mutated trees
  before authorization — rejected.
- Delta archive, not full-tree (owner-decided 2026-08-14 after measurement):
  full-tree re-stores content git already proves safe (~7×–25× vault bloat on the
  measured pile); the delta set needs no classification because "what git doesn't
  hold" is computed, not guessed.
- Extend `worktree_gc.py` in place: the sweep owns discovery, occupancy, budget,
  journal; a separate salvager module fails the deletion test. `od-wip` is a
  separate CLI because its callers are agents/humans, not the sweep — different
  seam, real boundary.
- Session attachment via installed `list-sessions --json`, not a Python
  reimplementation of the session ledger: one liveness source of truth.
- Claim-time `git worktree lock` lease rejected as primary: dead sessions never
  release locks, recreating the pile. Manual lock remains the explicit opt-out.
- Vault outside every repo (owner-chosen): never committable, survives repo
  deletion.
- `wip/<label>-<date>-<sha7>` on origin (owner-chosen namespace): content-addressed
  suffix defuses concurrent-push races; matches the park-WIP-on-`wip/*` convention.
- 3d universal TTL, 1d merged-clean grace: owner-approved; grace kept because
  occupancy can miss a between-turns session even with the oracle.
- 60d vault expiry with one advance warning (owner-decided): 60-day-old WIP is
  practically unmergeable; the warning protects secrets/local files, not code.
