# Plan: build-box scratch garbage collection

Audience: AI coding agents first.

Slug: `buildbox-scratch-gc`.

**Repo root for this run: `/home/user/Projects/overdeck/.worktrees/buildbox-gc`** (branch `feat/buildbox-scratch-gc`, cut from `origin/main`). Every path below is relative to that root. NEVER edit `/home/user/Projects/overdeck` directly — it holds unrelated dirty work.

## Authority

`docs/specs/2026-08-05-buildbox-scratch-gc-design.md` is the contract. Read the section your task names. This plan assigns files, seams, and acceptance; it does NOT restate the spec.

The spec file exists only in the untracked tree at `/home/user/Projects/overdeck/docs/specs/2026-08-05-buildbox-scratch-gc-design.md`. Read it by that absolute path.

## Global constraints — apply to EVERY task

- Node ESM `.mjs`, plain JavaScript. Match the surrounding file's style exactly.
- The spec's **Invariants** section binds every task. A violation is a rejection, not a tuning question.
- **NEVER restore `now - ts > 86400` as a floor-loop eligibility test.** That line is the defect being removed. A red pre-existing test asserting it must be inverted, never satisfied.
- **NEVER let a test touch the configured `remote_root` or any path under `~/builds` on any machine.** Every test fabricates its own `remote_root` under a temp dir.
- No stubs, no placeholder adapters, no fake success paths. Fail closed on ambiguity.
- GC never fails its caller. Log and return.
- A repo-wide tooling or dependency warning outside your Files list is the owner's to fix, not yours. Report it and judge your task on its own Files list.

## Wave plan

| Wave | Tasks |
|---|---|
| 1 | T1, T2, T3 |
| 2 | T4, T5, T6 |
| 3 | T7 |

Same-wave tasks have strictly disjoint file sets.

---

## Task 1 — GC module: ownership, release, orphan sweep, floor rewrite (L0 seam, L1, L2, L3)

Files (yours alone): `modules/workstation/claude/lib/remote-build-gc.mjs`

Spec sections: **L0**, **L1**, **L2**, **L3**, **Invariants**, **Error handling**, **Configuration**.

Implement in this one module:

- `ownerRecord(root, now): string` — the single-line `.rb-owner` payload. Exported; T2 imports it.
- `releaseMirror({ cfg, root, ssh, log }): { host, status, detail }[]` — L1. `status` ∈ `released | busy | absent | error`.
- `sweepOrphanMirrors({ cfg, stateDir, ssh, log, now, protect, force }): { removed, busy, skipped }` — L2. `force` bypasses the throttle; the stamp is still written.
- `gcScript(remoteRoot, opts)` — rewritten per L3. Remove the `now - ts > 86400` floor eligibility test and the `retentionDays` age sweep. Grace sweep keys on **`.rb-owner` absent regardless of `.rb-epoch`**. Floor loop uses age as ordering only, and prints `gc-floor-exhausted <freeGb>` rather than stopping silently.
- Config: add `gc_sweep_interval_minutes` (10) and `gc_unowned_grace_hours` (24) to `GC_DEFAULTS`. **Remove `gc_interval_hours` and `gc_retention_days` outright.**
- `sweepRemoteMirrors` (the existing export) is subsumed by `sweepOrphanMirrors`. Keep whichever name the callers in T2 use — the spec names `sweepOrphanMirrors`; if you remove `sweepRemoteMirrors`, say so in your report so T2's contract matches.

The host-side release script must apply the live-job guard: a mirror is live iff some `~/.rb/jobs/<id>/meta.json` has `mirror` equal to this mirror path, that job dir has no `rc` file, **and** the job dir's mtime is newer than `gc_job_stale_hours` (default 6). Unreadable job meta ⇒ treat as live.

**The mtime clause is load-bearing, not a refinement.** Measured on debian1: 47 job dirs have no `rc`, the oldest 190 hours old — crashed jobs that never wrote a return code. Under a bare "no `rc` ⇒ live" rule each of those pins its mirror permanently, reproducing the exact immortality bug this whole change removes. Abandoned job dirs are reaped along with the mirror.

Add `gc_job_stale_hours` (6) to `GC_DEFAULTS` alongside the other two new keys.

Do NOT edit the test file. T5 owns it. Your acceptance is a syntax/behavior smoke of your own module.

Acceptance: `cd modules/workstation/claude && node --input-type=module -e "import('./lib/remote-build-gc.mjs').then(m=>{const need=['ownerRecord','releaseMirror','sweepOrphanMirrors','gcScript','GC_DEFAULTS'];const miss=need.filter(k=>!(k in m));if(miss.length)throw new Error('missing '+miss);if('gc_interval_hours' in m.GC_DEFAULTS||'gc_retention_days' in m.GC_DEFAULTS)throw new Error('removed keys still present');if(/86400\s*\)\s*\]\s*\|\|\s*continue/.test(m.gcScript('/tmp/x',{minFreeGb:1,repoRetentionDays:30,unownedGraceHours:24})))throw new Error('floor age gate still present');console.log('T1 OK')})"`

Commit only: `modules/workstation/claude/lib/remote-build-gc.mjs`
Message: `Reclaim remote mirrors on run completion instead of on a clock`

---

## Task 2 — Owner record write, exclude sets, allocation-time sweep

Files (yours alone): `modules/workstation/claude/lib/remote-build.mjs`

Spec sections: **L0**, **L2** (call site), **Invariants**.

- In `syncPush`, write `${mirrorPath}/.rb-owner` containing `ownerRecord(root, now)` imported from `./remote-build-gc.mjs`. The record is one line: `{"root":"<abs local root>","client":"<os.hostname()>","updated":<epochSeconds>}`.
- Add `.rb-owner` to the rsync **push exclude set** and to `DEFAULT_REMOTE_CONFIG.pull_excludes`, beside `.rb-epoch`. Missing either side makes rsync delete the record it just wrote.
- In `tryRemoteBuild`, call `sweepOrphanMirrors` **before** `syncPush`, passing the about-to-build mirror as `protect`. Replace the existing `sweepRemoteMirrors` call site. Keep the `deps.` injection seam the existing code uses so tests can stub it.

T1 owns `remote-build-gc.mjs`. Import from it; never edit it. Its exported signatures are pinned above — treat them as given.

Acceptance: `cd modules/workstation/claude && node tests/remote-build.test.mjs && node tests/remote-argv.test.mjs && node --input-type=module -e "import('./lib/remote-build.mjs').then(async m=>{const src=await (await import('node:fs/promises')).readFile('lib/remote-build.mjs','utf8');if(!src.includes('.rb-owner'))throw new Error('no owner write');console.log('T2 OK')})"`

Commit only: `modules/workstation/claude/lib/remote-build.mjs`
Message: `Stamp each remote mirror with the local root that owns it`

---

## Task 3 — Host-side backstop shipped through modules/buildbox (L4)

Files (yours alone): `modules/buildbox/bin/buildbox-gc`, `modules/buildbox/lib/buildbox-checks.sh`

Spec section: **L4**.

- New `modules/buildbox/bin/buildbox-gc`: host-local reclaim. Reap `~/.rb/jobs/<id>` whose `rc` file is older than 1 day, **and** job dirs with no `rc` whose mtime is older than `gc_job_stale_hours` (abandoned). Measured: 5,510 job dirs on debian1, 10,293 on debian2 — nothing reaps this directory today. Reap mirrors with **no `.rb-owner`** older than the grace window regardless of `.rb-epoch`. Reclaim to the disk floor using the same live-job-guarded oldest-first policy as L3. MUST NOT delete an owned mirror above the floor — it cannot see local roots.
- `modules/buildbox/lib/buildbox-checks.sh`: add `item_scratch_gc` in the file's existing declarative `item_*` / `ok|bad|fixed` / `MODE=audit|bootstrap` form. Audit reports drift when the systemd user unit or timer is missing, inactive, or stale; bootstrap installs and enables an hourly timer.

Read `modules/buildbox/lib/buildbox-checks.sh` first and match its idiom exactly. Do not invent a new reporting convention.

Acceptance: `cd modules/buildbox && bash -n bin/buildbox-gc && bash -n lib/buildbox-checks.sh && grep -q 'item_scratch_gc' lib/buildbox-checks.sh && echo 'T3 OK'`

Commit only: `modules/buildbox/bin/buildbox-gc`, `modules/buildbox/lib/buildbox-checks.sh`
Message: `Converge an hourly scratch reclaim timer onto every build box`

---

## Task 4 — `remote-mirror` CLI

Files (yours alone): `modules/workstation/claude/bin/remote-mirror`

Spec section: **L1** (CLI paragraph), **L2** (force).

Three subcommands over `lib/remote-build-gc.mjs`:

- `remote-mirror release <root>` — L1 for the given local root, across every configured host. Prints one status line per host. Exit 0 on `released` or `absent`; non-zero only on `error`.
- `remote-mirror sweep [--force]` — L2. `--force` bypasses the throttle.
- `remote-mirror status` — per-host mirror inventory with owner root, live-job flag, and size.

Executable bit set. Loads config through the existing `loadRemoteConfig` export in `lib/remote-build.mjs`; never re-parse the config file.

T1 and T2 own the library files. Import only.

Acceptance: `cd modules/workstation/claude && test -x bin/remote-mirror && bin/remote-mirror --help >/dev/null && echo 'T4 OK'`

Commit only: `modules/workstation/claude/bin/remote-mirror`
Message: `Expose mirror release, sweep, and status as a callable command`

---

## Task 5 — Unit coverage

Files (yours alone): `modules/workstation/claude/tests/remote-build-gc.test.mjs`

Spec section: **Testing** — all 14 numbered cases are required, plus the disposition table for the three existing cases.

Load-bearing:

- Case 8 (**floor evicts a same-day idle mirror**) is the regression that wedged the box. The existing case at line 68-69 asserts the opposite ("never same-day ones"); **invert it**, do not delete it.
- Delete the retention case at line 31 — `gc_retention_days` no longer exists.
- Rewrite the unmarked-mirror case at line 43 to key on `.rb-owner` absence, not `.rb-epoch` absence.
- Case 14: a mirror with `.rb-epoch` but no `.rb-owner` is reclaimed past the grace window and spared inside it. This is the pre-upgrade orphan class.
- Case 15: an `rc`-less job dir older than `gc_job_stale_hours` does NOT make release report `busy` — mirror and job dir are both reclaimed. A fresh `rc`-less job dir still reports `busy`. 47 such abandoned dirs exist on debian1 today; without this the guard makes their mirrors immortal.
- Every test fabricates its own `remote_root` under `mkdtempSync`. **NEVER the real one.**

T1 owns `lib/remote-build-gc.mjs`. If a case fails, report it — do not edit the library to make it pass.

Acceptance: `cd modules/workstation/claude && node tests/remote-build-gc.test.mjs`

Commit only: `modules/workstation/claude/tests/remote-build-gc.test.mjs`
Message: `Cover mirror release, orphan sweep, and same-day floor eviction`

---

## Task 6 — Release at worktree teardown

Files (yours alone): `modules/workstation/claude/workflows/lib/finish-branch.sh`, `modules/workstation/claude/bin/wt-reaper.sh`

Spec section: **L1** (call sites).

- `finish-branch.sh`: the ephemeral land worktree is created at line ~441 via `mktemp -d "$cand_base/candidate-XXXXXX"`. Immediately after that worktree is removed, invoke `bin/remote-mirror release <that worktree path>`. **Non-fatal** — a failed release must never fail a land. Cover every removal path, not just the happy one.
- `wt-reaper.sh`: same call per reaped worktree, same non-fatal contract.

Invoke the CLI **by absolute path derived from the script's own location**, never by bare name on `PATH`. T4 owns the CLI; its contract is `remote-mirror release <root>`, exit 0 on released/absent.

Acceptance: `cd modules/workstation/claude && bash -n workflows/lib/finish-branch.sh && bash -n bin/wt-reaper.sh && grep -q 'remote-mirror' workflows/lib/finish-branch.sh && grep -q 'remote-mirror' bin/wt-reaper.sh && echo 'T6 OK'`

Commit only: `modules/workstation/claude/workflows/lib/finish-branch.sh`, `modules/workstation/claude/bin/wt-reaper.sh`
Message: `Release a mirror the moment its worktree is torn down`

---

## Task 7 — Shell integration coverage for the CLI

Files (yours alone): `modules/workstation/claude/tests/remote-mirror-cli.test.sh`

Spec section: **Testing** (shell-side paragraph).

Model on `tests/remote-build-integration.test.sh`. Drive `bin/remote-mirror release` end to end against a **fabricated** `remote_root` over loopback ssh, asserting exit codes and printed status tokens (`gc-released`, `gc-busy`, `gc-absent`). Skip cleanly with a clear message if loopback ssh is unavailable — never fake a pass.

**NEVER point the test at the configured `remote_root` or `~/builds`.**

Acceptance: `cd modules/workstation/claude && bash tests/remote-mirror-cli.test.sh`

Commit only: `modules/workstation/claude/tests/remote-mirror-cli.test.sh`
Message: `Exercise the mirror release command end to end`
