feat(bin): reclaim Next.js build output from pooled copies - #2632
Closed
tiago-peixoto wants to merge 15 commits into
Closed
feat(bin): reclaim Next.js build output from pooled copies#2632tiago-peixoto wants to merge 15 commits into
tiago-peixoto wants to merge 15 commits into
Conversation
Stock macOS Bash 3.2 - what /usr/bin/env bash resolves to on the machines
this fleet runs on - terminates the whole shell when `.` cannot find its
file, and does so with status 0. It ignores both the `|| return 1` written
beside the source and the `if !` the caller wrapped the call in.
So a missing backend adapter ended fm-teardown.sh mid-run while reporting
success: the caller records the task as cleaned up when nothing was cleaned
up, and the safety refusal teardown was about to print never appears. Bash 5
returns 1 as written, which is why the Linux CI lanes never saw it and
tests/fm-teardown.test.sh's herdr preflight case failed only locally.
Test the adapter file before sourcing it, through one helper the five
backends share. The guard is read and written with eval because this file is
also sourced from zsh, where ${!var} and printf -v do not mean what they mean
in bash.
Verified against GNU bash 3.2.57(1)-release (arm64-apple-darwin25); the new
case in tests/fm-backend.test.sh fails without this change.
A pooled task copy returned to the pool still holding its Next.js build output. `treehouse return` resets tracked content and leaves gitignored output alone, so nothing ever removed it and it accumulated copy by copy until the volume filled. Measured 2026-08-18: 15 GB in one idle Artemis copy on a volume with 11 GB free; ~25.6 GB across eight copies on 2026-08-07. Two surfaces over one discovery rule in bin/fm-next-cache-lib.sh: - fm-teardown.sh reclaims the copy on its way back to the pool, which is where the problem is created. It runs after every unlanded-work refusal has passed and after the worktree's processes are reaped, so nothing is still writing it, and it is never fatal: the output regenerates from source, so it is never the work product a refusal protects. - fm-next-cache-sweep.sh reclaims copies already sitting idle in the pool. One command, no daemon, watcher, schedule, or disk-pressure trigger. A copy must be proven unowned on all four counts or it is skipped: the pool reports it available, no task record in this home or a registered secondmate home names it, its tree is clean, and it holds no stashes. The pool's lease comes first because the pool is shared across firstmate homes and it is the only ownership signal that spans them - and because a live dev server rewrites the output the moment you delete it. Every skip is reported with its reason, every reclaim with the space it gave back, and an empty sweep says so rather than printing nothing. Only directories named .next that git ignores AND that sit beside a Next.js app root are ever removed, so source, node_modules, and git data are out of scope by construction rather than by exclusion list. Next.js documents .next as the build output directory and clears it itself on every production build, so this is the same operation the framework performs one build earlier. A project with a custom distDir is deliberately not discovered: reading a build config to decide what to delete would make the deletion set depend on project code. Other large caches were measured and left out rather than swept by pattern: a project's .tmp scratch root (~8.7 GB pooled) holds audit output, coverage reports, and browser recordings - agent work product that nothing regenerates. .turbo, .cache, .parcel-cache, .vite, and .output all measured zero.
The reclaim is a deletion, so the question that matters is not what happens when the unownedness proof succeeds - it is what happens when the proof cannot be made at all. The suite covered owned copies and the clean case but not that middle ground. Every input the sweep relies on now has a case that breaks it and asserts the build output survives: an unrecognized pool status, an unreadable pool, a failing pool lookup, a missing treehouse, a path that is no longer a git worktree, and a worktree whose git state cannot be read. "Could not determine" now reads as "owned" everywhere, and an unknown pool status is no longer folded into the in-use reason it does not mean. Reporting changed with it. A copy whose ownership could not be established is always named, because the same broken inspection that hides its owner also hides how much it is holding - the old size-gated skip line would have passed such a copy over in silence, which reads as "nothing here". Teardown's answer to the same question is ordering, so that is now pinned too: a case places a live process in the copy and observes, from where the pool return happens, that the build output was already reclaimed and the process already reaped. Moving the reclaim after the return fails it.
The suite's fixture commits inherited the host's commit.gpgsign, so on a machine that signs by default with no key available they failed to write a commit object and the whole script died before its first assertion - exit 128 with no "not ok" line, which reads as a pass to anything counting failures. Each fixture git invocation now passes -c commit.gpgsign=false explicitly, so these cases depend on no personal signing key and no ambient harness setting.
Author
|
Closing in favour of a split. The general fixes from this branch - the stock macOS Bash 3.2 defect where sourcing a missing sibling library ends a script silently with status 0, and the teardown paths that swallowed the resulting failure - now live in #2646. The remaining content here is the Next.js build-output reclamation, which assumes a specific worktree-pool layout and is going to a fork instead of upstream. No commits from this branch are deleted; it remains available for reference. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Make Next.js build-cache reclamation structural instead of something noticed only when the disk fills.
Why now, with measured numbers: on 2026-08-18 one idle Artemis copy held a 15 GB packages/frontend/.next on a volume with 11 GB free; reclaiming that single directory took free space to 27 GB. A 2026-08-07 measurement found ~25.6 GB spread across eight copies. This is pure build output that regenerates, so it is the largest low-risk reclaim available, and it keeps coming back because nothing removes it.
Two pieces were asked for, and only two:
Explicit constraints the user set:
Decisions and tradeoffs made while doing the work, which a reviewer reading only the diff would not know:
A later user requirement, accepted and implemented: because the change makes teardown DELETE something before returning a copy, the tests must cover a copy that IS owned and a copy whose ownership CANNOT be determined, not just the clean case. Both are now covered - five owned cases and six cannot-determine cases, each asserting the build output survives.
One unrelated pre-existing bug was found and fixed in a separate commit, deliberately included rather than left broken: on stock macOS Bash 3.2 (what /usr/bin/env bash resolves to on the machines this fleet runs on),
.on a missing file terminates the whole shell with status 0, ignoring both the|| return 1beside the source and the enclosingif !. A missing backend adapter therefore ended fm-teardown.sh mid-run while REPORTING SUCCESS, so a caller records a task as cleaned up when nothing was cleaned up and the safety refusal never prints. Bash 5 returns 1 as written, which is why the Linux CI lanes never saw it and tests/fm-teardown.test.sh's herdr preflight case failed only locally. fm_backend_source now tests the adapter file before sourcing it, through one helper the five backends share; the guard is read and written with eval because that file is also sourced from zsh, where ${!var} and printf -v do not mean what they mean in bash. A new case in tests/fm-backend.test.sh fails without the fix.Three other pre-existing local test failures were investigated and deliberately NOT fixed, because they are host-environment limitations rather than code defects and would pass on CI's Linux runners: tests/fm-kimi-harness.test.sh (python3 on PATH is 3.8.20, and tomllib needs 3.11+), tests/fm-muse-harness.test.sh (macOS SIGKILLs a copied system bash, so the ancestry fixture cannot launch - verified rc=137), and tests/fm-composer-lib.test.sh (half-block glyph matching under Bash 3.2's weak multibyte support). Fixing three more unrelated subsystems would have expanded this task well past its scope.
RE-SCAFFOLD, 2026-08-18: this branch was re-created from upstream/main on the captain's explicit ruling and the work cherry-picked across unchanged. The first attempt was branched from the fork's main, so its rebase gate correctly warned that 35 unrelated fork commits (78 files) would enter the upstream pull request. The captain ruled to make that gate's premise true rather than approve or skip it, and this branch is the result: it now carries exactly four commits relative to upstream/main and nothing else. The carried patch was verified byte-identical to the pre-re-scaffold work - the only difference between the two diffs is one AGENTS.md hunk header line number, because upstream's AGENTS.md is longer than the fork's; the inserted line lands in the same paragraph, under the same anchor sentence.
A fourth commit was added during the re-scaffold and is part of this change: the suite's fixture commits inherited the host's commit.gpgsign setting, which on the upstream base made them fail to write a commit object and killed the script with exit 128 before its first assertion - a crash that prints no "not ok" line and therefore reads as a pass to anything counting failures. Every fixture git invocation now passes -c commit.gpgsign=false explicitly, so these cases depend on no personal signing key and no ambient harness setting.
Deliberately NOT included, because it would import unrelated fork divergence into an upstream pull request: upstream's tests/lib.sh has no commit.gpgsign guard, while the fork's does. On a host that signs commits by default, pristine upstream/main already fails tests/fm-teardown.test.sh (exit 128) and tests/fm-backend.test.sh (exit 1) for that reason alone, before any change here. Those are pre-existing upstream host-environment failures, verified against a clean upstream/main checkout, and fixing them belongs in its own change rather than being folded in here.
What Changed
.nexteligibility checks and a sweep command that reports or reclaims build output only from available, proven-unowned pooled copies, failing closed when ownership cannot be established.Risk Assessment
✅ Low: Captain, the changes now fail closed: explicit targets are report-only, deleting targets require primary-worktree identity, and duplicate pool fields are rejected.
Testing
The focused cache and backend suites passed on macOS Bash 3.2 after neutralizing the documented ambient signing setting for the backend retry; direct CLI transcripts demonstrate reported idle reclamation, teardown reclamation, source preservation, live-copy protection, and fail-closed unknown ownership.
Evidence: Cache sweep CLI evidence
Dry-run preserved output; reclaim removed only.next; in-use and undetermined copies preserved output and were reported.Evidence: Teardown CLI evidence
Teardown reported reclaim, exited successfully, removed.next, and preserved tracked Next.js source.Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 5 issues found → auto-fixed (6) ✅
bin/fm-next-cache-lib.sh:105- “Never delete anything that is not regenerable build output” is contradicted by checking only whether the.nextdirectory itself is ignored. Git does not track directories, so an ignored.nextcan contain force-added tracked descendants; line 163 then deletes them, and a later failed pool return leaves tracked content missing. Reject candidates whengit ls-filesreports any descendant, treating inspection failure as non-reclaimable.bin/fm-next-cache-sweep.sh:145- “Every ‘could not determine’ outcome is treated as ‘owned’” is contradicted by this pipeline. Withoutpipefail,treehouse statusmay emit valid JSON and exit nonzero while Python exits 0, allowing anavailableentry to reach deletion despite a failed authoritative pool lookup. Capture and check treehouse’s status before parsing, or protect the pipeline with pipefail.bin/fm-next-cache-sweep.sh:123- “A copy must be PROVEN unowned: no owning task record” is contradicted by silently skipping missing state directories, malformed registry entries, and failed metadata reads; the caller also ignores loader failure. An unreadable registered-secondmate record can therefore be omitted and its available, clean copy swept. Make task-record enumeration an all-or-nothing proof and refuse when any local registry or candidate metadata cannot be read.bin/fm-next-cache-sweep.sh:138- The required “no owning task record names it as its worktree” proof uses literalgrep -Fxpath equality. On case-insensitive macOS, differently-cased paths can identify the same directory but fail this comparison; if its lease is available or released, the remaining checks pass and a live task’s cache can be removed. Compare filesystem identity, such as device and inode, at this boundary.bin/fm-next-cache-sweep.sh:292- After a project’s pool lookup fails,RCchanges but no incomplete/unknown count is recorded, so the final output can still assert “no idle copy holds Next.js build output.” That contradicts the requested honest reporting because the sweep could not establish that fact. Track incomplete projects and report an incomplete sweep instead.🔧 Fix: Captain: fail closed on uncertain cache ownership
4 issues (2 errors, 2 warnings) still open:
bin/fm-next-cache-sweep.sh:167- “No task record ... names it as its worktree” remains bypassable becausestatdoes not follow a final symlink by default. If metadata names/pool/linkand treehouse reports its target, the literal paths and lstat inodes differ, so an available, clean copy reaches deletion; a broken final symlink also returns its own inode instead of the required unknown result. Make this boundary compare resolved referents and return undetermined when a final symlink cannot be resolved.bin/fm-next-cache-sweep.sh:124- The all-or-nothing task-record proof still accepts a syntactically parseable but non-absolute localhome:and then treats it as absent relative to the caller’s current directory. The shared registry contract rejects such homes as unsafe; here the intended home’s task records can be omitted and its copy swept. Before the absence exception, reject any local home that is not an absolute, resolvable registry path.bin/fm-next-cache-sweep.sh:216- “An unreadable or unparseable pool ... skips the copy” is contradicted by silently dropping dictionary entries with no usablepath. A status-0 response containing such an entry is accepted as a complete pool and can produce the absolute “no idle copy” summary. Treat a missing or non-string path as a malformed pool and make the whole project incomplete.bin/fm-next-cache-sweep.sh:313- “A copy whose ownership could not be established is ALWAYS reported by name” is contradicted by[ -d "$wt" ] || continue. A named pool entry whose path is missing or inaccessible disappears silently instead of being reported as undetermined. Pass it through the ownership verdict so identity failure produces the existing named skip line.🔧 Fix: Captain: fail closed on incomplete cache ownership
4 issues (2 errors, 2 warnings) still open:
bin/fm-next-cache-sweep.sh:235- The required rule that an incomplete ownership check must refuse is still bypassable: bothgrepcomparisons treat every nonzero status as “no match.” Ifgrepis unavailable or exits 2, an exact task-owned copy can be classified free and reclaimed. Distinguish no-match (1) from inspection failure and return the undetermined verdict on the latter.bin/fm-next-cache-sweep.sh:274- The malformed-pool refusal does not reject NUL characters. Python accepts fields such asavail\u0000able, but Bash command substitution cannot represent NUL and can collapse that value toavailable, authorizing deletion without the pool’s exact availability proof. Reject NUL before emitting the line protocol.bin/fm-next-cache-sweep.sh:421- The captain’s requirement that no silent set-narrowing survive remains contradicted: the oldgit ... || continuewas only rewritten as a nestedif. With one readable project and one project directory whose Git metadata cannot be inspected, the latter disappears fromTARGETSand the sweep can still claim completeness. Record that directory as an incomplete project or refuse discovery.bin/fm-next-cache-sweep.sh:464- If every attempted removal fails,RCbecomes 1 butRECLAIMED,SKIPPED, andINCOMPLETEremain zero, so the final summary falsely says no idle copy holds build output even though the failed directory remains. Track removal failures and qualify the summary accordingly.🔧 Fix: Captain: fail closed across cache ownership inputs
3 issues (2 errors, 1 warning) still open:
bin/fm-next-cache-sweep.sh:530- The required “sweep never touches the project clone” and task-record ownership proofs are not established by[ -d "$wt" ]plus inode identity.git rev-parse --git-diralso succeeds for repository subdirectories: a pool response naming the project clone deletes its.next, while naming a live copy’s child directory bypasses the task record for its root. Captain, this is the same completeness class after the promised exhaustive enumeration, so the stated boundary calls for removing sweep deletion authority rather than another local patch.bin/fm-next-cache-sweep.sh:698- A valid free copy can be inspected and planned before a later malformed entry refuses the project. The plan is then discarded without reporting that copy, butINSPECTEDremains incremented and the summary says “nothing to reclaim in 1 copy” even though its.nextremains. This contradicts the required honest summary and “ALWAYS reported by name” rule; keep project refusal atomic but treat discarded plans as incomplete, not empty.tests/fm-next-cache-sweep.test.sh:1559- IfFM_NEXT_CACHE_TESTcontains a misspelled or removed test name, every wrapper invocation silently skips and the suite exits successfully without running an assertion. Track whether the selector matched and fail when it did not, so targeted RED/green evidence cannot be vacuous.🔧 Fix: Captain: prove pool provenance and honest sweep outcomes
2 errors still open:
bin/fm-next-cache-sweep.sh:845- The required positive proof that a candidate “must not be the project clone” remains bypassable.sweep_projectaccepts an explicit project subdirectory becausegit rev-parse --git-dirsucceeds there; provenance then compares the candidate clone root against that subdirectory rather than the actual project root. Thus a pool response naming the clone can pass the worktree-root/registry checks and have its.nextdeleted. Require the project argument itself to equal its resolved--show-toplevelbefore pool lookup, or derive the primary clone identity independently. This also disproves the header inventory’s claim that every relevant input was accounted for; under the captain’s stated boundary, deletion authority should be reconsidered rather than applying another local patch.bin/fm-next-cache-sweep.sh:763- The requirement that a copy whose ownership cannot be established is “ALWAYS reported by name” is contradicted by returning on the first failed candidate preflight. If a complete pool listing contains an invalid first entry followed by other named copies, those later copies receive no verdict and are never reported, although the whole project is refused. Preserve atomic refusal, but evaluate or explicitly report every remaining pool entry as refused before returning.🔧 Fix: Correct wrong root verdict and reconcile cache candidates
2 errors still open:
bin/fm-next-cache-sweep.sh:1098- The required guarantee that “the sweep never touches the project clone” remains bypassable when an explicit argument is another linked-worktree root. Such a path equals its own--show-toplevel, so it passes here; provenance then treats that linked worktree—not the repository’s primary worktree—as the clone to exclude. If the pool response names the real primary clone, it is a distinct registered root and can reach deletion. Prove the supplied project is the primary worktree or derive the primary identity independently. This is the same destructive causal theme after multiple fixes, so I recommend withholding sweep deletion authority until that boundary is proven.bin/fm-next-cache-sweep.sh:615- The requirement that ambiguous pool input refuse reclamation is contradicted by parsing JSON with Python’s default duplicate-key behavior. A response such as{"status":"in-use","status":"available","path":"…"}is accepted with the last status winning, allowing conflicting authoritative lease evidence to becomeavailable. Reject duplicate object keys during JSON decoding before emitting any candidate rows; otherwise a malformed pool can still authorize deletion.🔧 Fix: Correct authority verdicts and reject duplicate pool fields
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
/bin/bash tests/fm-next-cache-sweep.test.sh/bin/bash tests/fm-backend.test.sh— relevant adapter case passed; later encountered the known ambient GPG-signing fixture issueGIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false /bin/bash tests/fm-backend.test.shControlledbin/fm-next-cache-sweep.sh --dry-runand reclaim checks against an available, unowned pooled copyControlled sweep checks with pool statusin-useand unknown, verifying.nextsurvivedControlledbin/fm-teardown.sh task-x1check for a landed copy holding.nextgit status --shortand evidence-file integrity checks✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.