0037 — Worktrees are disposable: the cleaner owns idle and merged ones, and the disk is a measured quantity
- Status: Accepted. The build is in
gera-propflow/local-bin(thecleanerworktree phase,host-headroomdisk bars, thefleet-graderdisk row, thedriver-brieffloor line). - Date: 2026-09-29
- Deciders: Gera, 2026-09-29, after the mini's disk filled: "I don't think we have proper garbage collection and cleaning, so I would kind of really go deep so we can clean up, 'cause we've been dancing on the edge for a while."
- Amended 2026-09-30: worktree
node_modulesis a link layout into an immutable shared install keyed by the lock hash. Gera chose it over a warm pool of reusable worktrees ("Linked shared install"). See the section of that name below. - Amended 2026-10-01: the session that merged takes its own worktree down (
wt-merged-notify→wt-done). Amended 2026-10-03: a copy whose PR merged or closed goes at the cleaner's next hourly run, not after 48h. See the amendments of those dates below.
What was measured (2026-09-29)
- The disk ran out. The mini's 1 TB data volume hit "No space left on device" at 20:06–20:10Z.
- The lease hook fails closed on a write it cannot make, so for four minutes every Driver's prompt was refused (
REFUSED — cannot prove session … has no other live copy: OSError [Errno 28]). - The medic heartbeat, the host snapshot and the relay-deploy snapshot failed to write in the same window.
- Nothing measured disk, so nothing told anyone.
host-headroomsampled memory, swap and the compressor every 5 minutes, and never looked at the disk.
- The lease hook fails closed on a write it cannot make, so for four minutes every Driver's prompt was refused (
- Worktrees are where it went.
- There were 821 registered
propflowaiworktrees, and about 335 carried anode_modulesthatdureports at roughly 3 GB each. That figure is apparent size, not disk used — see the correction below. - About 790 of the 872 GiB in use sat under
~/code. Everything outside it (caches, transcripts,~/.claude/jobs) came to roughly 80 GiB. - 275
propflowaiworktrees were created on 2026-09-29 alone.
- There were 821 registered
- Nothing ever removed a worktree. The
cleanerspecialist (SYSTEMS.md) is described as tearing down "worktrees a closed task left", but its only scheduled mode archives task rows. This is the cleaner's own founding shape, work that scales with item count with nothing retiring the dead ones, in a new place. - A one-time sweep worked, and it only moves the cliff.
- A sweep of 60 worktrees idle for more than 48h took free space from 28 to about 75 GiB. What those bytes were was not measured per category. The sweep removed each worktree's
.nextas well as itsnode_modules, and other sessions were cleaning up at the same time. Given the correction below (clones free about 0 bytes), the gain was most likely.nextbuild output, plus any unmarked installs that predate the 2026-09-25 clone hook. It should not be read as evidence that deleting clones frees space. - At 275 new worktrees a day, the same cliff returns within days unless retirement is continuous.
- A sweep of 60 worktrees idle for more than 48h took free space from 28 to about 75 GiB. What those bytes were was not measured per category. The sweep removed each worktree's
Correction, measured the same evening
.husky/post-checkout already shared one install. From 2026-09-25 until the 2026-09-30 amendment below, every new propflowai worktree got an APFS clonefile of main's node_modules: copy-on-write, blocks shared. Gera approved "share one install" before this was re-checked; it was already true. It has since been replaced by links (see "Linked shared install").
- 24 of 25 sampled worktrees carry the hook's provisioning marker with main's exact lock.
- Three
clonefiles ofnode_modules/next(199 MB, 8,941 files) moved free space by noise only (+44 / −17 / +50 MB), and git ref writes stayed at 0.05–0.11 s. Clones are free to KEEP. ⚠️ That no-stall reading was a partial-tree sample. A whole-tree clone (200k+ entries), measured later, stalls git ref writes box-wide for its full 70–95 s. See "Linked shared install" below, which is why worktrees no longer clone. - The unique disk is build output: 46 GiB across 46
.next/cache dirs even after the first sweep. One worktree's.next/devwas 2,197 MB in 2,773 entries. - Deleting one
.nextfreed 1,458 MB in 7.4 s, and ref writes stayed at 0.06–0.12 s. - Deleting clones is the expensive act. A clone has 130k+ entries and frees about 0 bytes, and it is what stalled git ref writes box-wide three times that evening.
The decision
A worktree is disposable once git has its work elsewhere. The
cleanerremoves a linked worktree only when all of the following hold:git status --porcelainis empty;- HEAD is reachable from
origin/<branch>, fromorigin/main, or from the head of its merged or closed PR (that covers squash merges whose branch was deleted); - its PR is merged or closed, or there is no PR and HEAD is in
origin/main; - no non-dependency file changed in the idle window;
- no live process has its cwd inside it;
- no task, loop or lane record names it;
- it was not created in the last 24h;
- it is not under a protected root (
~/.claudeby default: live state and session scratch).
The edge these guards carry alone. A worktree just branched from main with no commits of its own is clean, pushed and in
origin/main, and has no PR, so it passes every test except the 24h birth guard and the idle window. Those two guards are its only protection. Neither may be relaxed (in particular, the birth guard must stay at least as long as the tightest idle window) without first adding a stronger test for the no-commit case, such as requiring a merged or closed PR. Superseded in part by the amendment of 2026-10-03: that stronger test is built (HEAD at or behind its merged/closed PR's head), and for a copy that passes it both guards give way to a 1h quiet window. For every other copy this paragraph stands.This is the one place the cleaner deletes rather than moves. The task phase moves because a task record has no other copy; a worktree that passes these tests has one on origin.
Build caches go first, and clones never go.
.next,node_modules/.cacheand.turboare reaped from any worktree idle 12h, clean or not. They are pure regenerable output, and they are where the bytes are.- A
node_modulescarrying the hook's provisioning marker is a clone of main's install. It is never reaped: keeping it is free, and deleting it is the stall. - An unmarked
node_modulesis an install of its own, owns its blocks, and is reaped at the full idle window. - Two markers, one rule. A linked
node_modules(2026-09-30) carries both markers..pf-sharedis written by the link helper, and.worktree-provisionedby the hook once the layout is in place. Either marker means it is never reaped..pf-sharedadditionally makes removing the worktree cheap, because it contains only links, so that removal is allowed outside the nightly window. A tree with neither marker is the only one treated as an install of its own. - How "marked" is read: the reaper skips a
node_modulescarrying either marker (.worktree-provisionedor.pf-shared). There is no lock comparison. A marked tree is a hook-provisioned one, either cloned or linked. The 24-of-25 sample, where every marked tree also had main's exact lock, was taken before the link layout existed, and covers clones only. - The known edge. A marked clone into which a lane later runs
npm installkeeps the marker, but by then it owns new blocks. This rule never reaps it, so those bytes are only reclaimed when the worktree itself is removed in the nightly window. Treating a lock that diverges from main's as "unmarked" would close the gap. It is a follow-up, not built. - A dirty or unpushed worktree keeps every file of its own.
What git does not have is never deleted, only listed. A worktree that is idle and dirty, or has commits that exist only locally, goes to
~/.claude/jobs/worktrees-needing-human.json. It is Gera's call, never the cleaner's. Reader: the coordinator surfaces the list to Gera. Until afleet-graderrow counts it (the named follow-up), it is a file with one human reader, not an alarm. Superseded in part by 9: an idle held copy is now archived to origin first, after which git does have its work, and it may then be removed. The list keeps everything the cleaner will not act on: copies idle under 24h, copies whose archive failed or is too large, copies on a person's branch, and saved copies still inside their 7 days (each listed with its archive tag).It runs on the cleaner's existing hourly cadence, with no new launchd job.
worker-cleaner --mode=apply(launchdco.propflow.worker-cleaner, hourly) runs the task phase, then the worktree phase.- The worktree phase is bounded per run by small caps. The code (
WT_MAX_REMOVE,WT_MAX_REAP) is the one place those numbers live. - Deletes are paced and stall-probed (local-bin #304). Files are unlinked in batches, and after each batch a git ref write is timed in a private scratch repo. If it is slow, the run stops and writes
BLIND why=fs-stall-during-delete. A volume that is already stalled deletes nothing. Git reads run withGIT_OPTIONAL_LOCKS=0, so inspecting hundreds of worktrees never rewrites their indexes. - The hourly chained phase is opt-in. It runs only while
~/.claude/jobs/cleaner-worktrees.enabledexists, and that file is created only after a live paced run has shown git ref writes staying fast. Running--worktreesby hand always works. - Reaping takes a little every hour on purpose; removal happens only in the nightly window below. On 2026-09-29, deleting about 80 GB in one burst coincided with a fleet-wide stall: git ref writes sat in uninterruptible wait for up to 35 minutes across repos, and all of them released at 22:23:43Z once the bulk deletes and scans stopped. A GC that stalls the fleet is the incident it exists to prevent.
- A crash in it is logged and never changes the task phase's exit code.
- Whole-worktree removal runs only in a nightly window (UTC 08–11 by default, 02:00–05:00 in Monterrey). Removal deletes the worktree's clone, which frees almost nothing and carries the stall risk; its benefit is only keeping
git worktree listshort.
The idle window tightens under pressure. It is 48h normally, and 12h once the volume is at 92% used or more.
BLIND beats wrong.
- The phase refuses to act, and writes
cleaner-worktrees BLIND why=…, when it cannot read either of the two things every verdict needs:- the process list;
- the task records.
- When the PR list is unreadable it reaps build caches, and unmarked installs past the full idle window, and writes BLIND. None of those depends on PR state. It never reaps a marked (cloned or linked) tree and never removes a worktree.
- The liveness line
cleaner-worktrees mode=is written only when a run actually judged.
- The phase refuses to act, and writes
The disk is measured and alarmed where the fleet already carries alarms.
host-headroomsamples disk use every 5 minutes. Past 85% it reports PRESSURE with adisk N%>85%reason, and an unreadable disk is its own reason, never read as fine.fleet-stabilityalready postshost-headroom's reasons verbatim to Slack. No second threshold exists to drift.fleet-gradergains adiskrow. It is GAP past 85%, and GAP if the worktree GC has not judged a run in 3h, because scheduled is not judging.driver-briefprints the disk floor as its first line.
Lanes must push before going idle. A worktree whose only copy of a commit is local is protected forever, so the lane that made it should push. The protection is what keeps the rule safe, and pushing is what keeps the disk free. Superseded in part by 9: "protected forever" now means protected until the cleaner has archived it to origin and 7 idle days have passed. Pushing is still the lane's job; 9 is the net for when it doesn't happen.
Amended 2026-09-29: a held copy is saved, not kept forever. Rule 8 depends on a lane remembering to push, and on the mini on 2026-09-29, 161 held copies showed it doesn't. They were mostly helpers that died mid-edit. So the
cleanerarchives any dirty or unpushed worktree idle past 24h: its whole tree becomes one commit on top of its HEAD, built in a temporary index so the copy is never written, pushed torefs/tags/archive/worktrees/<date>/<name>and read back from origin. A saved copy may be removed only after 7 days idle, in the nightly window, and only while the tag is still on origin and the copy's tree still equals the archived tree. A failed push deletes nothing. A person can approve removals earlier throughcleaner-worktrees-queue.json, and the same archive and tree checks still apply (local-bin #308).- What an archive holds, and what it does not. It holds tracked edits, untracked files that are not gitignored, and commits that exist only locally. It does not hold gitignored files (
.env*,node_modules, build output, local databases), and removing the copy loses them. That is the intended trade: they are regenerable or machine-local by definition. Stashes live in the shared repo, not the worktree, so removal leaves them. So do secrets in ignored files: they are never pushed. A secret in a tracked or non-ignored file would be pushed, and GitHub's push protection then refuses it. The archive fails and nothing is removed. - Two clocks, not one. The 24h archive threshold and the 7-day removal threshold are independent of decision 5's 48h/12h window, which still governs dependency reaping and the removal of clean, pushed copies. Disk pressure tightens only decision 5's window. It never shortens the 24h or the 7 days.
- Retention: archive tags are never pruned automatically. They are small, because they share objects with the repo. Deleting one is a person's decision, taken knowing that a copy removed under it then exists nowhere.
- The queue: the writer is a person, or a coordinator carrying out a person's recorded approval. The 2026-09-29 sweep wrote it with
stranded.py queueafter Gera's "Yes, all five steps". The only reader is thecleaner's worktree phase, which applies every check in this decision to each entry. Nothing else reads it. - A person's branch (
gera/,fede/,jose/) is never archived and never removed by idle age. It is only listed, because their work-in-progress is not the fleet's to publish.
- What an archive holds, and what it does not. It holds tracked edits, untracked files that are not gitignored, and commits that exist only locally. It does not hold gitignored files (
Linked shared install (decision of 2026-09-30)
Why a second decision was needed. Measured on the mini on 2026-09-29, a whole-tree clone of node_modules (200k+ entries) is nearly free on disk. But for its full 70–95 s, git ref writes and new-file creates stall for every process on the volume:
- a ref-write probe read over 6 s throughout, and starting git itself stalled;
fsync, directory reads and starting system binaries did not;- git's own fsync settings made no difference;
- cloning package by package only stretched the stall over 384 s.
Every new worktree ran one such clone, which at about 275 worktrees a day meant hours of frozen git. Gera chose "Linked shared install" over a warm pool of reusable worktrees. The trade-off as it was put to him:
- The pool removes the per-task clone and delete entirely. But it changes how every lane and person gets a worktree:
start-driver,spawn-pairand handgit worktree addall become a lease from a pool. It also shares one working directory across tasks over time, with leftover untracked files,.envand.next. - The link layout leaves
git worktree addunchanged. Its risk was Turbopack's root, which was verified and handled below.
The record of his reasoning is the choice itself.
The decision: a worktree's node_modules is a link layout into one immutable shared install keyed by the package-lock.json sha256.
- Where it lives. The shared install is
~/.cache/pf-node-modules/<sha256>/.- It is built once by
npm ci(withHUSKY=0, since it is not a git repo), under a per-hash lock. - Then it is made read-only (
chmod -R a-w). - A lock change produces a new hash directory. An existing install is never changed.
- It is built once by
- The layout. A worktree's
node_modulesis a real directory:- one symlink per package;
- a real directory of symlinks for each
@scope; - workspace packages linked to the worktree's own
packages/*; .package-lock.jsoncopied, never linked. On 2026-09-29,npm install --dry-runrewrote it through a link into the shared install..cache,.viteand.vitestas real directories;- a
.pf-sharedmarker naming the install.
- Turbopack. It refuses modules whose real path is outside
turbopack.root. With the root pinned to the project, every page returned 500: "node_modules/next was removed, renamed, or has a broken symlink".next.config.jswidens the root to the common ancestor of the project and the shared install, and does so only whennode_modules/.pf-sharedexists.- The main checkout, CI and Vercel have no marker and are unchanged.
- Verified in a linked worktree:
next dev(Turbopack),next build, vitest,tsgoand eslint.
- Who provisions.
.husky/post-checkoutremains the ONE provisioner. It tries the link layout first, throughscripts/worktree-node-modules.mjs. It falls back to the old clone path, unchanged, in any of these cases:- node or the helper is absent;
npm cifails;PF_NM_MODE=cloneis set.
- The escape hatch for
npm install <pkg>isnode scripts/worktree-node-modules.mjs private ..- It removes the links, then runs the hook with
PF_NM_MODE=cloneto make a private copy under the hook's lock. - Letting npm convert the tree itself would re-extract every package (measured: "changed 1424 packages").
- It removes the links, then runs the hook with
- The cleaner.
- A linked
node_modulesis never reaped. - A linked worktree may be removed at any hour, because its
node_modulesis only links. - A shared install is kept while any worktree's
.pf-sharednames it. The one matchingorigin/main's lock is always kept. - An unreferenced install older than 24 h is reaped only in the nightly window, paced and stall-probed.
- A linked
Measured in a single run (an observation, not a controlled benchmark; the clone path's own fetch time was not separated out). Three worktree creates with the new hook and nothing else running:
| Create | Time | Why |
|---|---|---|
| 1 | 70 s | the first git fetch (a cold-start cost, not the steady state) |
| 2 | 5.1 s | |
| 3 | 5.5 s |
A ref-write probe sampled once a second (82 samples) and read 0.11 s worst, with none over 1 s. With the clone path, the same operation stalled over 6 s for 70–95 s per create.
Known limit.
- Building a new shared install, and later reaping one, still creates or deletes about 130k entries. That stall happens once per lock change, not once per worktree.
- Rollout step, not a note.
git worktree addruns the main checkout's copy of the hook (husky's hooks path is relative), so each machine gets the change only when its main checkout pulls it. Until then its worktrees still clone and stall.- On the Mac mini on 2026-09-30, the main checkout was 216 commits behind, carried someone's uncommitted edits, and its pull job (
co.propflow.propflowai-main-pull) was exiting 2. - The rollout is done on a machine only once its main checkout's
.husky/post-checkoutcontains the link path.
- On the Mac mini on 2026-09-30, the main checkout was 216 commits behind, carried someone's uncommitted edits, and its pull job (
Amendment of 2026-10-01: the session that finished cleans up after itself
What happened. The volume filled again: 0 GB free at 22:51 on 2026-09-30, the second time in two days. The cleaner's worktree phase had never been enabled (~/.claude/jobs/cleaner-worktrees.enabled did not exist), so only the hourly cache pass ran. At that point 491 of 688 propflowai worktrees belonged to PRs already merged or closed.
A stopgap built that night without reading this ADR made things worse. It was an hourly sweep plus a parallel day-time rm -rf of whole worktrees, with an override that ignored disk wait under 15 GB free: exactly the bulk delete this ADR forbids. It was retired the next morning. Its two launchd jobs (co.propflow.next-janitor, co.propflow.wt-sweep) were booted out and deleted.
Decision (Gera, 2026-10-01). "Whenever we merge something it should prompt [the session], kind of like the self-compact prompt: it's been merged, go ahead and take down the worktree to clean up, no permission is needed … so I don't get asked."
A merged or closed PR's worktree is taken down by the session that worked in it, minutes after the merge.
wt-merged-notify(local-bin, launchdco.propflow.wt-merged-notify, every 120 s) finds the merge and the owning live session.- It prompts that session through
msg. The prompt carries the standing permission: no permission is needed, and the session must not ask. - A worktree with no live owner stays the cleaner's.
The removal goes through
wt-done, andwt-donedeletes only through this ADR's own machinery.- It imports
worker-cleanerand deletes with its paced, stall-probed_rm. - It removes a worktree outright only when
_cheap_to_removeholds: a linked.pf-sharedinstall, or nonode_modules. - An older full-clone worktree gets only its build output deleted. Build output is the disk that is actually used. The folder waits for the nightly window.
- On
FsStallit stops and says so. - So decision 4 (no daytime bulk deletes) still holds.
- It imports
wt-donerefuses rather than guesses, in any of these cases:- the caller's cwd is inside the worktree;
- a session was launched inside it;
- another process works inside it;
- its PR is not merged or closed.
Leftover uncommitted changes are archived locally (
~/.claude/jobs/host/wt-archive/<name>-<sha>/), and the branch is never deleted.This is the fast path, not a replacement.
- The cleaner remains the backstop for sessions that died, sessions that ignore the prompt, and worktrees with no PR.
- Enabling its worktree phase (decision 4's opt-in) is still owed.
Amendment of 2026-10-03: a finished copy goes at the next hourly run
Decision (Gera, 2026-10-03). "Remove finished working copies as soon as their PR merges, instead of waiting 48 hours."
What was measured, 2026-10-03 ~09:00. 231 propflowai worktrees. 38 had a MERGED or CLOSED PR, and 34 of those were under 48h old, so decision 5's idle window held them for up to two days after their work was already on origin. The 10-01 fast path (wt-merged-notify → wt-done) covers only a copy with a live owning session; a copy whose session died, or with a stray shell inside, falls back to the cleaner and waited the full 48h. The cleaner's hourly worktree phase (decision 4's opt-in, owed since 10-01) was enabled at ~09:05 that morning after a paced apply run removed 10 with git ref writes staying fast.
- A DONE copy is removable at the next hourly run. DONE means three things: its PR is MERGED or CLOSED; its HEAD is that PR's final head or an ancestor of it; and the copy was born before the PR closed (
closedAt/mergedAtfrom the PR list; unknown means never done). The copy holds exactly what the PR carried, and it existed while the PR was open. For a DONE copy the 48h/12h idle window and the 24h birth guard are replaced by a 1h quiet window (WT_DONE_QUIET_Hin the code) measured on two clocks: the PR closed at least an hour ago, and nothing other than dependencies changed in the last hour. The edit clock alone was not enough — a copy held quiet for hours and merged at 14:59 would have gone at the 15:00 run, beforewt-merged-notifyhad prompted its owner.wt-merged-notifyprompts the owner within minutes of the merge, and the phase runs hourly, so 1h is "the next run" without racing a session that just merged and is still reading its own tree. (local-bin #369) - The stronger test decision 1 required is now built, so its "neither may be relaxed" paragraph is superseded for DONE copies only. HEAD in
origin/mainalone is not DONE: PRs are matched by branch name, so a fresh no-commit copy on a reused name would have HEAD in main and pass. The ancestry test against the PR's own head excludes that. The ancestry test alone is also not enough, found by the fresh-context review of local-bin #369: a no-commit copy on a reused name created from a staleorigin/main(the mini's main checkout has been 200+ commits behind), or after main was merged into the old branch, has a HEAD that is an ancestor of the old PR's head. The birth-before-close test excludes that: a copy born after its PR closed cannot be that PR's working copy. A no-commit copy with no PR keeps both guards; a reused-name copy with commits beyond the PR head fails the ancestry test and keeps the 48h path. Each of the three tests has a fixture that removes the copy when that test is dropped. A copy created and merged within one day goes once merged: the birth guard existed for the no-commit edge, and DONE already excludes that edge, so keeping it would hold exactly the copies merge-day churn creates. - Everything else holds. Clean tree; no live cwd; named by no task, loop or lane record; not under a protected root; PR list readable (BLIND means DONE is never true). Dirty or unpushed copies never take this path, whatever their PR says: they keep decision 9's 24h save and 7-day wait, and a merged-but-dirty copy under 24h idle is still only kept, not listed and not deps-reaped. Pacing, the stall probe, the per-run cap and the nightly window for full (non-linked) removals are unchanged; a linked copy goes any hour, as since 09-30.
- The cap stays at 10 per run, deliberately. 38 eligible ÷ 10 = four hourly runs. A linked removal is about 27k entries (measured: 25,128 source files plus 1,538 links); a full one 160k+. The one burst that ever stalled the box was ~80 GB of unpaced
node_modules. If merges outpace 10/h at peak (275 creates a day is ~11/h), the follow-up is a separate cap for cheap removals that leaves the nightly full-removal burst at 10, not a blanket raise. - CLOSED is deliberate. Gera's words were "merges"; this amendment covers MERGED and CLOSED because decision 1 has treated a closed PR as "git has its work elsewhere" since 09-29 (the commits sit at
refs/pull/N/headand usuallyorigin/<branch>), and the PRs this fleet closes by automation (cleaner --prs, ORPHANED and idle 7d) belong to copies already past the 48h window. What a closed copy loses is its ignored files, the same trade every removal under this ADR makes. - Accepted residual edge, named, not fixed. The only live-use guard is "a process has its cwd inside", and for a DONE copy its window drops from 48h to 1h. A session that works in a merged copy by absolute path, reads without writing for over an hour after the merge, and ignores
wt-merged-notify's prompt, loses the copy; its work is on origin. The cleaner does not scan transcripts for owners the waywt-merged-notifydoes. If that ever bites, the fix is to readwt-merged-notify's owner index here, not to lengthen the quiet window.
Consequences
- The disk stops being an unmeasured cliff. An alarm fires at 85%, the sweep tightens at 92%, and a full disk is visible in every Driver's first screen.
- Worktrees now have a lifetime, so a lane coming back to one after 48h idle may find its dependencies gone and has to run
npm ci. This is the intended cost. - History under
~/.claude/projectsand the ccswitch session-cfg transcripts (about 15 GiB),~/.claude/jobs,smith-state/and the Temporal databases are out of scope. They are history or live state, not disposable.