Add model-tidy: safe plan/apply tool for idle LLM model cleanup - #128
Conversation
Moves idle local model directories (HF cache models--*, ~/models/*,
ad-hoc project dirs) off a full GPU-box drive onto another mount,
leaving a symlink behind so existing paths and docker bind mounts
keep working.
- plan (default, read-only) vs apply (needs --apply AND a verified,
writable, cross-filesystem --target).
- Selection rules run in order with an explicit skip reason each:
KEEP list, process/fd + serving-process cmdline in-use checks,
running-container docker bind mounts (fail-safe to "all of
~/.cache/huggingface is in use" if docker is unreadable),
--min-idle-days, already-a-symlink, and hardlink sets that must
move together as one unit or not at all.
- apply copies with a hand-written pure-Node recursive copy that
explicitly recreates hardlinks via dev:ino tracking (not a shelled
rsync, to keep the repo's zero-dependency Node-only posture and
keep tests deterministic), verifies every file byte-for-byte
(size + SHA-256) before ever deleting the source, then symlinks
and re-verifies. Any failure leaves the source untouched.
- systemd/model-tidy.{service,timer} for a nightly plan-only run
(not installed by this change); docs/model-tidy.md covers install,
the selection rules, and a "not verified" list.
- test/model-tidy.test.mjs: fixture-based plan test (idle dir +
complete hardlink set selected, correct skip reasons for KEEP/
recent/already-tidied/broken-hardlink-set/docker-unreadable/
process-in-use/max-gb-capped) plus an apply test and a negative
control (corrupted target checksum -> refuse, source untouched).
Never run against a real machine; --dry-run-remote is read-only
plan-mode-only ssh helper, off by default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Lead review of the apply path, read from the branch, not from the report. Safety ordering holds: One hardening suggestion, not a blocker: between Confirmed from the report and worth keeping visible in the PR: this has never executed |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 104d7c9cf5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function sha256File(path) { | ||
| const hash = createHash('sha256'); | ||
| hash.update(readFileSync(path)); | ||
| return hash.digest('hex'); |
There was a problem hiding this comment.
Stream checksums instead of reading model shards whole
For typical multi-GiB .safetensors or .gguf shards, readFileSync materializes the entire file in one Buffer; under supported Node 18 versions, sufficiently large shards exceed the Buffer limit, while smaller ones can still exhaust available memory. The copy then succeeds but verification throws, so affected model units can never be applied; compute SHA-256 incrementally with a read stream instead.
Useful? React with 👍 / 👎.
| rmSync(src, { recursive: true }); | ||
| symlinkSync(dst, src); | ||
| const real = realpathSync(src); | ||
| if (real !== realpathSync(dst) || !lstatSync(src).isSymbolicLink()) { |
There was a problem hiding this comment.
Preserve the source until symlink replacement succeeds
When symlink creation or post-link verification fails, the source has already been recursively deleted, contradicting the failure guarantee and leaving a missing or dangling source path. This is reproducible with an existing cross-filesystem target passed as a relative path: validation accepts it, but symlinkSync(dst, src) interprets dst relative to the source's parent, and realpathSync(src) then fails after deletion. Normalize paths and use a reversible rename/rollback or staged atomic replacement.
Useful? React with 👍 / 👎.
| if (m.Source && (path === m.Source || path.startsWith(m.Source + sep))) { | ||
| hits.push({ container: (c.Name || '').replace(/^\//, '') || (c.Id || '').slice(0, 12), source: m.Source, destination: m.Destination }); |
There was a problem hiding this comment.
Treat nested Docker bind sources as in use
If a running container bind-mounts a subdirectory of a candidate, such as ~/models/foo/weights while the candidate is ~/models/foo, this containment test returns false because it only checks whether the candidate is below the mount source. The parent can consequently be selected and removed while its child is actively mounted; the check must also recognize m.Source.startsWith(path + sep).
Useful? React with 👍 / 👎.
| const minIdleDays = args['min-idle-days'] !== undefined ? Number(args['min-idle-days']) : 14; | ||
| const maxGb = args['max-gb'] !== undefined ? Number(args['max-gb']) : Infinity; |
There was a problem hiding this comment.
Reject invalid idle-day values before planning
When --min-idle-days is mistyped, for example --min-idle-days fourteen, Number(...) produces NaN; every ageDays < minIdleDays comparison is then false, silently bypassing the freshness guard and allowing recently modified models to be moved during an apply. Validate that this option is finite and nonnegative before constructing the plan.
Useful? React with 👍 / 👎.
| const procResult = listProcessUsers(c.path); | ||
| const fdHit = (procResult.users || []).find(u => u.via === 'fd'); | ||
| const cmdHit = (procResult.users || []).find(u => u.via === 'cmdline' && u.servingProcess); | ||
| const hit = fdHit || cmdHit; |
There was a problem hiding this comment.
Fail closed when process usage cannot be checked
When /proc is unavailable or cannot be listed, findProcessUsers returns {checked:false, users:[]}, but this code treats the empty list exactly like a successful check and can select a model that is currently serving. This affects non-Linux systems and Linux hosts with restricted proc access; skip the candidate or abort apply whenever checked is false rather than silently continuing.
Useful? React with 👍 / 👎.
…h window, hardlink scope Addresses an independent static review (codexmb) of commit 104d7c9: 1. FAIL-OPEN in plan. findProcessUsers silently swallowed per-pid permission failures and still reported checked:true; an unreadable docker only fail-safed ~/.cache/huggingface, leaving ~/models/* and ad-hoc dirs selectable. Now: any candidate whose in-use status can't be fully verified (any unreadable pid, /proc unreadable, docker present but not inspectable) is skipped with "in-use status unverified: <why>", never treated as idle for lack of evidence. An unreadable docker now taints every candidate on the box. The plan summary line reports "N unverified". 2. CRASH WINDOW in apply. The old sequence did rmSync(src) then symlinkSync(dst, src) — a crash between those two calls left the source gone with no symlink. Now: renameSync(src, src+'.tidy-moving') -> symlinkSync(dst, src) -> re-verify -> rmSync(staging). Any failure after the rename removes the half-made symlink and renames staging back to src, so a unit's source ends up either fully in place or fully swapped to a working symlink, never neither. A leftover '<src>.tidy-moving' from a previous interrupted run is detected up front and that unit is refused untouched. 3. HARDLINK SCAN SCOPE. Clarified that computeHardlinkGroups already scans every discovered candidate (KEEP-listed, recent, and in-use dirs included, not just the plain-idle subset), and fixed the skip message to name the actual blocking member ("hardlinked to <path>, which is not moving (<path>'s own reason)") instead of a confusing self-referential wrapper. Added a regression test proving an idle dir hardlinked to a KEEP-listed copy is skipped as a unit. Also, while touching the same code paths: docker bind-mount detection now also catches a mount SOURCE nested under a candidate (not just the reverse), --target must be an absolute path, and the CLI rejects a non-finite/negative --min-idle-days instead of silently disabling the freshness guard via NaN. Both are from the same automated review but outside the three defects above. 6 new tests (fail-closed process/docker checks with a positive control, hardlink-to-KEEP-listed skip, crash-window rollback, leftover-staging refusal) plus one existing test's fixture fixed (it relied on touching a hardlinked file's mtime, which — being the same inode — moved both copies' mtime and no longer exercised what it claimed to). Tests: 19/19 model-tidy (new tests pass standalone and in the full model-tidy run), 678/678 full repo suite. Not done: the streaming-checksum (large-file readFileSync) and lsof-fallback findings from the same review are out of scope for this pass and documented in docs/model-tidy.md's "Not verified" section. Still never run against a real machine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed the three safety defects from codexmb's static review (commit 104d7c9) in 0f6b682. 1. FAIL-OPEN in plan (most serious). 2. CRASH WINDOW in apply. Old sequence was 3. HARDLINK SCAN SCOPE. Also fixed while touching the same code, from the same review but outside the three defects: docker bind-mount detection now also catches a mount source nested under a candidate (not just the reverse), Test results: new tests pass standalone ( Not done in this pass (same review, out of scope for the three defects, documented in docs/model-tidy.md's "Not verified"): Leaving this in draft per the lead's instruction. 🤖 Generated with Claude Code |
…naled crash recovery Addresses codexmb's re-review of 0f6b682, with exact reproductions (probe.mjs), plus a follow-up amendment from the lead overriding three points of the original ask. GAP 1: hardlink to a file outside every discovered candidate root. computeHardlinkGroups only ever saw candidates it had itself discovered, so a second link sitting outside every discovered root (e.g. directly under $HOME, above ~/models) was invisible no matter how wide the scan went. Now every file's st_nlink is compared against how many links this scan actually observed under the scanned roots; a shortfall skips the whole candidate with "hardlinked N times, only M links found under scanned roots; refusing incomplete set" — checked before every other rule, since it's a filesystem fact, not a policy choice. GAP 2: a hard crash (SIGKILL/OOM/power loss, not a thrown exception) at various points during the swap-to-symlink step. The old rename-then- symlink sequence's in-process try/catch rollback cannot run at all when the process is hard-killed instead of throwing, which the review's probe demonstrated with a real process.exit() in a child. Per the lead's amendment, the fix is journal-based, not a bigger try/catch: - plan is READ-ONLY. It never recovers anything, including interrupted state — it only detects it (via the journal) and reports it per unit as "interrupted move found: ...", refusing to select that unit. A new `recover` subcommand is the only place (besides apply's own start) that mutates. - Before touching a unit at all, apply writes a journal record (JSON, one file per unit under <home>/.cache/ide-agent-kit/model-tidy-journal/, written with an explicit fsync) holding the source/staged/temp-link/ target paths and the per-file size+SHA-256 manifest already computed. The record is rewritten (and re-fsynced) after every subsequent step: pending -> linked -> staged -> swapped -> removed on cleanup. - Recovery is journal-driven, not naming-driven: a directory literally named *.tidy-moving or *.tidy-link with no matching journal record is reported and left alone, never touched. A journaled unit is only acted on after its manifest is re-verified against whatever currently exists on disk; a mismatch is also left alone and reported. - The exact safety claim, as specified: "the original data is preserved and recoverable at every step; the original path is unavailable for the instant between the two renames, and until recovery runs if a crash lands there. It is not continuously available." Not claiming more than that. New tests (11, all pass standalone and in the full suite): GAP 1 reproduces codexmb's probe verbatim (link outside home/models) plus a positive control (all links inside the tree); GAP 2 crashes a real child process at each step (before either rename via the symlink hook, right after the link is verified, between the two renames, and after the second rename before cleanup — including codexmb's exact probe kept verbatim), asserts the tree is whole after one recovery pass, and covers the amendment's required cases: plan makes zero filesystem writes (before/after full tree snapshot, sizes+mtimes) while still reporting the interrupted unit; recovery never touches an unjournaled *.tidy-moving directory; recovery refuses a journaled unit whose on-disk content no longer matches its manifest. Test results: 11/11 new tests in isolation, 29/29 full model-tidy suite, 688/688 full repo suite. Not done: the two previously-flagged Codex findings (streaming checksums for large files, no lsof fallback) remain out of scope and are noted in docs/model-tidy.md, which now also notes computeManifest/verifyManifest share the same whole-file-readFileSync property. The journal-based recovery itself has only been exercised against synthetic fixtures on this dev machine — never against asus1/asus2, a real multi-GiB model, or a genuinely full disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed the two remaining gaps from codexmb's re-review of 0f6b682, in e45642e. Read the probe (/tmp/codex-pr128-review.9e6lTY/probe.mjs) and reproduced both before fixing. GAP 1: hardlink to a file outside every discovered root. GAP 2: hard crash between rename and symlink. The probe's
Covered by (all crash a real child process via
Test results: 11/11 new tests in isolation ( Not done, noted in docs/model-tidy.md: the streaming-checksum and Leaving this in draft per the lead's instruction. 🤖 Generated with Claude Code |
…staged original
codexmb reproduced a real data-loss path in recoverInterruptedMoves:
source GOOD, apply interrupted between the two renames (afterStage),
target then corrupted to BADD, recovery reported "completed-swap-and-
cleaned" and deleted the last good original — source ended up reading
BADD. Root cause: recovery validated the staged original's own manifest
and that a realpath existed for the pending link, but never re-verified
the TARGET's actual content against the manifest, and never checked that
the symlink's realpath was exactly the journal's recorded target path.
"A link resolves to something" was being treated as proof of what it
resolves to. It isn't.
Fix: one guarded function, finalizeSwapOrRestore, is now the ONLY place
in the codebase allowed to delete a staged original — used by both
apply's own final step (same run) and recoverInterruptedMoves (a later
run). It requires all three, every time, even for a link already
verified once when it was created:
1. every file under the journal's target path matches the manifest by
size and SHA-256 exactly (no extra or missing paths either).
2. the source path is a symlink whose realpath resolves to exactly the
journal's recorded target path.
3. the staged original itself, if it still exists, still matches the
manifest exactly (a partially damaged original is reported, not
silently discarded because a symlink elsewhere looks fine).
Any failure: remove a bad/half symlink at the source path if present,
restore the staged original to the source path if one exists, mark the
journal record `step: 'failed'` with the reason, and report — never
delete on the strength of "a link resolves".
New tests (4, all pass standalone and in the full suite): codexmb's
round-2 probe kept verbatim (source reads GOOD after recovery, never
BADD, unit reported failed rather than "completed"); the mirror case
where the target is fine but the source symlink was swapped to point
elsewhere (recovery must not delete the staged original just because
some link resolves); a positive control where target and link are both
genuinely correct (recovery completes and cleans normally); and the same
corrupt-target scenario exercised through apply's own final cleanup
path in the same run, not only through a later recover pass. Also fixed
two pre-existing tests whose expected action name/behavior predated this
guarded function (the old two-step 'completed-swap-from-link' /
'finished-cleanup' split is now unified, and correctly, into a single
verified 'completed-swap-and-cleaned' or a restore-on-failure).
Test results: 4/4 new tests in isolation, 33/33 full model-tidy suite,
692/692 full repo suite.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed the data-loss path codexmb reproduced in recovery, in 8df7bd0. Read the probe (/tmp/codex-pr128-round2.pjlier/probe.mjs, read-only) and reproduced it exactly before fixing. Confirmed bug: source GOOD, apply interrupted between the two renames (a thrown exception during Root cause: recovery validated the staged original's own manifest and checked that Fix: one guarded function,
Any failure: remove a bad/half symlink at the source if present, restore the staged original to the source path if one exists, mark the journal record Defect -> test mapping:
Also fixed two pre-existing tests whose expectations predated this function (the old two-step Test results: 4/4 new tests in isolation, 33/33 full model-tidy suite, 692/692 full repo suite. Leaving this in draft per the lead's instruction. Nothing run on asus1/asus2. 🤖 Generated with Claude Code |
…table
codexmb's round-3 probe found the mirror of the round-2 data-loss bug:
apply interrupted afterSwap, one file removed from the STAGED original
(partial cleanup) while the destination was GOOD and the live source
symlink was valid. The round-2 fix's single pass/fail collapsed straight
to "restore" whenever ANY check failed — including "the staged backup
doesn't match" — which unlinked the correct, working symlink and
replaced it with the known-damaged backup. Confirmed against pre-fix
code: sourceHasWeights=false (file missing) while targetBytes="GOOD"
(the live target was fine the whole time).
finalizeSwapOrRestore is now an explicit 4-row decision table over three
independently re-checked facts (targetOk, linkOk, stagedState — absent /
matching / damaged, no longer a single boolean):
1. targetOk && linkOk && stagedState != damaged
-> complete: finish any pending rename, delete the staged copy if
it matched, clear the journal. (existing)
2. targetOk && linkOk && stagedState == damaged
-> the live path is correct and complete: PRESERVE it untouched.
Never delete the damaged backup silently: rename it to
<sourcePath>.tidy-quarantine-<journalId>, record that path in
the journal (step 'completed-partial-staging-quarantined'),
report it. Nothing is deleted in this row.
3. (!targetOk || !linkOk) && stagedState == matching
-> restore: remove a bad/half symlink wherever it sits, rename the
staged original back to sourcePath, re-verify, journal 'failed'.
(existing behavior, now only reachable in this row)
4. (!targetOk || !linkOk) && stagedState != matching
-> nothing verified good anywhere: DELETE NOTHING, RENAME NOTHING
— not even a still-pending, uncompleted rename — leave every
path exactly as found, journal 'failed-both-copies-damaged'
with the per-check reasons, report loudly with every path.
Also fixed a related bug found while implementing row 4: the caller
(recoverInterruptedMoves) used to complete a pending rename (temp-link
-> source) speculatively, BEFORE calling finalizeSwapOrRestore, on the
theory that "completing it alone never deletes anything" — but that
still violates row 4's "rename nothing" requirement when the final
decision turns out to be row 4. finalizeSwapOrRestore now owns that
rename entirely, performing it only inside the row-1/row-2 branches that
have already decided it's warranted, never speculatively.
New/updated tests (all pass standalone and in the full suite): row 2
rewritten as codexmb's exact partial-staging scenario (live symlink
preserved, damaged backup quarantined, nothing deleted); a dedicated row
3 test reproducing the round-3 probe's own 'bad-target' scenario
(afterSwap crash, not afterStage) to prove linkOk — a path check — does
not imply target content is correct; a new row 4 test (target corrupted
AND staging partial) asserting a full before/after tree snapshot of
every data path (sizes + SHA-256) is byte-for-byte identical across two
consecutive recovery passes, and that the report names every path
involved. Also fixed the exclusion logic so a quarantined path (and any
journal record naming one) doesn't get double-reported as an unrelated
orphan stray, and excluded quarantine-suffixed names from
discoverCandidates so a quarantine directory is never treated as a new
candidate model.
Test results: 3/3 new/changed tests in isolation, 35/35 full model-tidy
suite, 694/694 full repo suite.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed the mirror data-loss case codexmb's round-3 probe found, in 05588ee. Read the probe (/tmp/codex-pr128-round3.pHg5hh/probe.mjs, read-only) and reproduced both its scenarios before fixing. Confirmed bug: apply interrupted Fix:
While implementing row 4 I found a related bug of my own: Row -> test mapping:
Also fixed two smaller issues found while wiring this up: a quarantined path (and its owning journal record) was being double-reported as an unrelated orphan stray by the general stray-scan — excluded it once its journal record actually carries Test results: 3/3 new/changed tests in isolation, 35/35 full model-tidy suite, 694/694 full repo suite. Leaving this in draft per the lead's instruction. Nothing run on asus1/asus2. 🤖 Generated with Claude Code |
…durable journal writes/reads codexmb's round-4 review flagged two structural issues in the recovery machinery added over the previous three rounds, both confirmed in src at 05588ee before fixing. BLOCKER 1: three call sites removed a path they believed was a symlink using rmSync(path, {recursive:true}) without verifying that belief first — finalizeSwapOrRestore row 3 (two sites: the sourcePath obstacle and the pending-link obstacle) and recoverInterruptedMoves's pre-swap stray-link branch. If a real directory ever sat at that path instead (or the symlink pointed somewhere unexpected), a recursive remove could destroy an entire directory tree that was never model-tidy's to touch. Replaced all three with one helper, unlinkOwnedSymlink(path, expectedTargetPath): lstatSync first — not a symlink at all, or missing, refuses and touches nothing; is a symlink, but its realpath doesn't resolve to exactly the journal's recorded target, refuses and touches nothing; only then unlinkSync (never rmSync, never recursive). Every caller now treats a refusal as "left alone, reported, journal step 'failed'", never a reason to fall back to something more aggressive. BLOCKER 2: writeJournalRecordSync did open(file,'w') + write + fsync — a crash between the truncating open and the write leaves an empty or partial record at the path readers expect, which would make a real, in-flight unit look like there's nothing to recover. Changed to write-temp-fsync-rename: write the full record to <file>.tmp-<pid>-<random> in the same directory, fsync and close that temp file, renameSync it atomically over the real path, then fsync the journal directory itself (best-effort — some platforms can't fsync a directory fd; that failure is swallowed since the rename is still atomic there). Readers (listJournalRecords, used by both detectInterruptedMoves and recoverInterruptedMoves) now treat a record that is missing, empty, truncated, unparseable as JSON, or doesn't match the expected schema as 'journal-unreadable' / 'left alone' — that unit is reported and never acted on by plan, apply, or recover. A stray <hash>.json.tmp-* file left over from an interrupted journal WRITE is recognized by name and reported the same way; it is never parsed as a record. New tests (8, all pass standalone and in the full suite): for each of the three unlinkOwnedSymlink call sites, a real directory with a sentinel file is substituted for the expected symlink and the sentinel is asserted to survive, the unit reported refused, and a full before/ after tree snapshot (sizes + SHA-256) proves nothing else changed; plus a dedicated test that a symlink pointing to the wrong target is refused, not unlinked. For journal durability: a record truncated to half its bytes and to zero bytes is reported unreadable by both plan and recover with every path unchanged (tree snapshot); a stray .tmp-* file is reported and never parsed as a record; a positive control confirms a complete, valid record still drives recovery normally. Test results: 8/8 new tests in isolation, 43/43 full model-tidy suite, 702/702 full repo suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed both round-4 blockers, in 18e9144. Read the probe (/tmp/codex-pr128-round4.HAIFHo/probe.mjs, read-only, same fixture style as round 3) before implementing. BLOCKER 1: never recursive-delete a path assumed to be a symlink. Three sites did Replaced all three with one helper, Tests (sentinel-file style, one per site, plus the wrong-target case):
Each asserts the sentinel file (or the wrong-target symlink itself) survives, the unit is reported refused, and a full before/after tree snapshot (sizes + SHA-256, not just mtimes) proves nothing else on disk changed. BLOCKER 2: journal durability. Changed to write-temp-fsync-rename: write the full record to Tests:
Test results: 8/8 new tests in isolation, 43/43 full model-tidy suite, 702/702 full repo suite. Leaving this in draft per the lead's instruction. Nothing run on asus1/asus2. 🤖 Generated with Claude Code |
…edSymlink site
Three items from the lead's round-6 review, all confirmed against src
before fixing.
ITEM 1: findProcessUsers's inner per-fd catch swallowed every error from
readlink/realpath — including EACCES/EPERM — treating them identically to
ENOENT (a benign race where the fd simply vanished mid-scan). That meant
a permission error while resolving one process's open files was silently
ignored instead of failing the check closed. Now: ENOENT is ignored and
counted as such; any other error (EACCES, EPERM, anything else) marks
that pid's check unverified, and the whole run fails closed for that
candidate with reason 'in-use status unverified: <pid> <code>'. Added an
injectable `realpathSync` option so tests can throw at the exact read
boundary instead of only mocking the whole `listProcessUsers` function.
ITEM 2: sha256File read whole files via readFileSync — real model shards
are routinely multi-GiB, which could exhaust memory or exceed Node's
Buffer limits. Replaced with bounded-memory streaming: readSync into one
reused 8 MiB buffer, updating the hash incrementally, never loading more
than one chunk at a time. Every hashing call site in the file (manifest
computation, manifest verification, and copy verification) shares this
one function, so the fix applies uniformly. Exported sha256File and the
chunk-size constant for direct testing.
ITEM 3: swapToSymlink's pre-swap verification failure branch did
rmSync(link, {recursive:true}) on a path only assumed to be the symlink
it had just asked to be created — the same class of bug fixed at three
other sites in round 4, just missed there since this site runs BEFORE
any journal step describes a "target" to verify against. Routed through
unlinkOwnedSymlink(link, dst) the same way; on refusal, the path is left
untouched, the journal record is marked step:'failed' with the reason,
and the thrown error names it.
New tests (7, all pass standalone and in the full suite): ENOENT and
EACCES injected at the actual readlink/realpath call boundary (not just
via listProcessUsers), plus a positive control and an end-to-end planRun
check that the skip reason names the pid and code; sha256File verified
against a whole-buffer digest for a file larger than the chunk size, and
against Buffer.allocUnsafe/Buffer.alloc instrumented to throw if any
single allocation exceeds the chunk size; a sentinel-directory test for
swapToSymlink's pre-swap cleanup mirroring the round-4 style — the
directory and a file inside it survive, the source is untouched, and the
journal record says failed.
Test results: 7/7 new tests in isolation, 50/50 full model-tidy suite,
709/709 full repo suite.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Round 6, all three items done, in e03d4dc. ITEM 1: findProcessUsers error boundary. The inner per-fd Tests: ITEM 2: sha256File memory. Replaced whole-file Tests: ITEM 3: swapToSymlink's pre-swap Test: Test results: 7/7 new tests in isolation, 50/50 full model-tidy suite, 709/709 full repo suite. Keeping this in draft — the lead said it should be complete after this round, but draft stays until they say otherwise. Nothing run on asus1/asus2. 🤖 Generated with Claude Code |
…tory-fsync failure Two gaps from codexmb's round-6 review, both confirmed against src with the provided probe before fixing. GAP 1: an unreadable journal record did not protect its unit. Confirmed with the probe: interrupt beforeLink, empty the journal record, rerun plan — it correctly reported journal-unreadable with path:null, but that finding was filtered out of interruptedByPath (which only keeps findings with a resolvable path), so the same source was still selected. Fixed by recovering ownership from the journal FILENAME: journalFilePath always names a record <journalKey(sourcePath)>.json, and that 64-hex-char key survives even when the record's content doesn't. plan now builds a candidate->journalKey map first and matches every unreadable/stray record's filename against it: a match refuses exactly that candidate with reason 'interrupted move with unreadable journal record: <file>'; a filename that cannot be matched at all (unparseable, or a key with no current candidate) makes plan refuse to select ANYTHING this run (globalJournalBlockReason, surfaced in the summary line) rather than guess. apply enforces the same rule even harder: after its own recovery pass, any remaining unreadable/stray journal file blocks the ENTIRE run before any mutation (before even --target validation) — 'unresolved journal state: <files>; run recover, or resolve by hand'. recover itself still only reports such files and leaves them, and every path, alone. GAP 2: writeJournalRecordSync's directory-fsync failure was caught and swallowed unconditionally, including EIO. Now: on the supported target (process.platform === 'linux', injectable for tests), ANY error from the temp-file fsync, the rename into place, or the directory fsync throws — uncaught by swapToSymlink, so it aborts the whole unit before whatever mutation that write was meant to guard, and no claim of durability is made. Off Linux, only ENOTSUP/EINVAL from the DIRECTORY fsync specifically is tolerated, attaching an explicit 'durability degraded: <code>' note to the result rather than pretending durability was achieved; EIO and everything else still abort there too. The fsync/ rename functions and the platform are both injectable through applyRun/recoverInterruptedMoves so this is tested without branching on the real process.platform. New tests (9, all pass standalone and in the full suite): GAP 1 — codexmb's probe verbatim (source not selected, reason names the record); a stray journal file with an unparseable name (plan selects nothing, apply refuses before any mutation, full tree unchanged); a positive control that a healthy journal-free run still selects the idle dir. GAP 2 — EIO on the directory fsync on a simulated Linux platform (apply aborts, source untouched, error names EIO and 'journal directory'); ENOTSUP on a simulated non-Linux platform (proceeds, durability-degraded note reported); EIO on the temp-file fsync (aborts on any platform). Test results: 6/6 new tests in isolation (grouped as 3+3 per gap, 9 assertions worth of coverage across them), 56/56 full model-tidy suite, 715/715 full repo suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Round 7, both gaps done, in 360d730. Read the probe (/tmp/codex-pr128-round6.bcpiZx/journal-probe.mjs, read-only) and reproduced GAP 1 before fixing. GAP 1: unreadable journal records did not protect their unit. Confirmed with the probe unmodified: interrupt Fixed by recovering ownership from the journal filename:
Re-ran the unmodified probe against the fix: Tests: GAP 2: directory-fsync failure was swallowed. Tests: Test results: 6/6 new tests in isolation, 56/56 full model-tidy suite, 715/715 full repo suite. Keeping this in draft. Nothing run on asus1/asus2. 🤖 Generated with Claude Code |
…ry, not after codexmb's round-7 review found an ordering bug: applyRun called the mutating recoverInterruptedMoves BEFORE checking for remaining unreadable journal state. Reproduced with the provided probe: one valid afterStage interruption plus an empty unknown.json. apply correctly returned the unresolved-journal failure, but had already promoted the valid unit's source to a symlink and deleted its staged original by the time it did. No data loss (the promotion was itself verified), but it broke the promised global no-mutation guarantee — "refusing" is supposed to mean nothing happened, not "something happened, and by the way we're refusing." Fixed by reordering: the preflight (listJournalRecords, filtered to corrupt/unreadable entries) now runs FIRST, before recoverFn is ever called. recoverInterruptedMoves never touches an unreadable/corrupt record either way (it always reports and leaves those alone), so the set of files this check sees is identical whether it runs before or after recovery — only WHEN it's allowed to act on that information changed. If anything is unresolved, apply returns immediately with recovered: [] and the reason naming every unresolved file; recovery only runs once the preflight is clean. The explicit `recover` subcommand's behavior is intentionally unchanged: it still recovers every valid journaled unit in one run while reporting (never touching) any unreadable one alongside them — documented as a single sentence in docs/model-tidy.md: "apply refuses globally on any unreadable journal; recover heals what it can and reports the rest." New tests (4, all pass standalone and in the full suite): codexmb's probe verbatim, ported (mixed valid afterStage interruption + empty unknown.json — apply refuses, full tree byte-identical before/after including the valid unit's staging still present and its source not yet a symlink, recovered: []); the lone-corrupt case (no valid interrupted unit at all) still refuses; a positive control that a valid interruption with no unreadable records still recovers and proceeds normally; and recoverInterruptedMoves on the same mixed fixture still heals the valid unit while reporting the unreadable one, confirming recover's behavior is unchanged by this fix. Test results: 4/4 new tests in isolation, 60/60 full model-tidy suite, 719/719 full repo suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Round 8, the ordering gap, fixed in 1da9d2b. Read the probe (/tmp/codex-pr128-round7.gO0idm/order-probe.mjs, read-only) and reproduced it before fixing. Confirmed bug: Fix: the preflight (unreadable/stray journal check) now runs strictly before Re-ran the probe against the fix: The explicit Tests:
Test results: 4/4 new tests in isolation, 60/60 full model-tidy suite, 719/719 full repo suite. Keeping this in draft. Nothing run on asus1/asus2. 🤖 Generated with Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1da9d2b72d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const homeSt = statSync(home); | ||
| if (st.dev === homeSt.dev) { | ||
| return { ok: false, error: `--target ${target} is on the same filesystem (dev ${st.dev}) as ${home}; refusing (must be a different mount)` }; | ||
| } | ||
| return { ok: true }; |
There was a problem hiding this comment.
Reject targets nested inside selected source directories
When the cross-filesystem target is mounted beneath a selected candidate—particularly --target ~/models/archive, since every entry under ~/models is itself a candidate—this validation succeeds. The copy destination for that candidate is then created inside its own source, and copyDir recursively discovers and copies the directory it just created, potentially filling the target filesystem or running into path-length limits. Reject source/target containment in either direction, or exclude the target hierarchy from the plan.
Useful? React with 👍 / 👎.
| // own pre-checks and copy/verify/swap loop — see recoverInterruptedMoves. | ||
| // Only reached here, in apply, never from planRun. Only reached at all | ||
| // once the preflight above confirms every journal file is trustworthy. | ||
| const recovered = recoverFn(home, { journalDir, journalFsyncSync, journalRenameSync, platform: journalPlatform }); |
There was a problem hiding this comment.
Abort apply when automatic recovery remains unresolved
When a valid journal describes damaged or otherwise unrecognized on-disk state, recoverInterruptedMoves returns actions such as left-alone-nothing-verified-good, left-alone-after-failed-verification, or error; this result is never inspected, so apply continues copying and swapping unrelated selected units. That contradicts the documented whole-run refusal for an interrupted unit recovery cannot resolve and makes further mutations while manual recovery is still required. Check the recovery outcomes and return an error before processing the plan if any journaled unit remains unresolved.
Useful? React with 👍 / 👎.
| const recovered = recoverFn(home, { journalDir, journalFsyncSync, journalRenameSync, platform: journalPlatform }); | ||
|
|
||
| const validation = validateTargetFn(target, home); |
There was a problem hiding this comment.
Validate the target before running mutating recovery
If --target is nonexistent, unwritable, relative, or on the source filesystem, this ordering still runs recovery first. Recovery can promote a pending link and delete its verified staged original before validation returns the target error, so an apply that claims to refuse an invalid target has already mutated the model tree. Run target validation before recoverFn so invalid apply invocations remain non-mutating.
Useful? React with 👍 / 👎.
| const logFile = writeRunLog(logDir, { | ||
| mode, | ||
| ...plan, | ||
| argv | ||
| }); |
There was a problem hiding this comment.
Keep plan usable without a writable log directory
Every plan invocation calls writeRunLog before entering the plan branch, despite the command's read-only contract. Besides creating files during a purportedly non-mutating run, an absent or unwritable --log-dir makes mkdirSync/appendFileSync throw before the plan is printed, preventing diagnostics on restricted or read-only hosts. Logging should be opt-in or best-effort for plan mode rather than a mandatory write.
Useful? React with 👍 / 👎.
What it does
Status: two rounds of independent static review (codexmb) found real issues, both fixed on this branch — see the PR comments for the full gap -> test mapping. Round 1 (commit 0f6b682): fail-open process/docker checks, an apply crash window, hardlink scan scope. Round 2 (commit e45642e): a hardlink partner living outside every discovered root, and a hard-kill crash window that the round-1 fix's try/catch couldn't reach — replaced with the journal-based recovery described below. Still in draft; not merge-ready until the lead says so.
model-tidymoves idle local LLM model directories off a full internal GPU-boxdrive (asus1/asus2: single 931 GB NVMe, 91%/95% full) onto another mount
(external SSD / NAS share), leaving a symlink behind so every existing path —
including a docker bind mount — keeps working.
plan(default): read-only, prints what WOULD move and why every othercandidate is skipped, with real GiB sizes (bytes / 2^30) and a
free-before/free-after estimate. Writes a JSON record per run.
apply: only with the explicit--applyflag AND--target </mount>,which must exist, be writable, and be on a different filesystem device
than
--home— checked viastat().dev, refused otherwise.Language: Node.js, matching the repo (README: "No dependencies. Node.js ≥ 18
only."), following the existing
src/<name>.mjs+bin/<name>.mjs+test/<name>.test.mjsshape used bysession-keepalive.mjs,room-automation.mjs, etc. No systemd pattern existed in this repo (it shipsmacOS launchd
.plists for its own daemons); the target hosts are Ubuntu, sosystemd/model-tidy.{service,timer}are new, self-contained units — notwired into the macOS installer.
One deliberate deviation from the brief: the copy step in
applyis asmall hand-written pure-Node recursive copy (
copyUnitPureNodeinsrc/model-tidy.mjs), not a shelled-outrsync. Reasons indocs/model-tidy.md, short version: keeps the zero-dependency Node-onlyposture the rest of the repo has, and
rsync -Honly preserves hardlinksbetween paths named in the same invocation — the pure-Node version tracks
dev:ino -> target pathexplicitly across a whole hardlink unit andfs.linkSyncs repeats, which is the same safety property but verifieddirectly by a test instead of relying on
rsyncflag behavior acrossmachines. Byte-for-byte verification (size + SHA-256 + symlink targets +
file-count) still happens after copy and before any deletion.
Selection rules (in order, each with an explicit reason string)
--keep-file, path/glob,~expanded,#comments) — always skipped./proc/*/fd, or arecognized serving process's (
vllm,llama-server,llama.cpp,sglang,exllama,tabby,ollama,mlx,text-generation) command linereferencing the path.
docker inspecton runningcontainers'
Mounts). Fail-safe: if docker is unreadable, ALL of~/.cache/huggingfaceis treated as in-use and the run says so.--min-idle-days(default 14).dev:inoacrossdifferent roots are grouped; the group is selected only if every member
independently cleared rules 1-5, otherwise the whole group is skipped with
a reason naming which member failed and why.
Remaining candidates sort by size (desc), capped by
--max-gb(whole unitsonly, deferred to next run if over budget).
Safety invariants (updated after two rounds of codexmb review — see PR comments for the full gap -> test mapping)
plannever writes to disk, full stop — including for a unit left mid-swap by an interruptedapply. It only DETECTS that (via a journal) and reports it asinterrupted move found: ...; it never mutates. Onlyrecover(a new subcommand) andapplyitself (once, at its own start) ever run recovery.applyrequires BOTH--applyand--target;--targetmust be an absolute, existing, writable path on a different filesystem device — enforced bystat().dev, not path string matching.applywrites a journal record (JSON, one file per unit, fsynced) with the source/staged/temp-link/target paths and the per-file size+SHA-256 manifest; it's updated after every step. Recovery is journal-driven, not naming-driven: a directory merely named like a leftover with no matching (and still-valid) journal record is reported and never touched.st_nlinkexceeds the number of links actually found under the scanned roots — a partner link can sit entirely outside every discovered root, which no amount of wider scanning closes in general.fs.rmSync(Node), never a shellrm -rf.--report-to-roomand any nightlyapplyare opt-in, off by default (the shipped systemd service runsplanonly).Install the timer (not installed by this PR)
Test output (verbatim,
node --test test/model-tidy.test.mjs)Full repo suite (
npm test): 672/673 pass. The 1 failure(
test/session-send.test.mjsidle-guard timing test) is pre-existing,untouched by this diff, and passes in isolation — it only flakes under
full-suite parallel load on this machine.
Not verified
--dry-run-remotein plan mode. Noapplyhas executed outside test fixtures.--dry-run-remoteagainst bothasus1andasus2(read-only, plan-mode-only, per the task's step 8): neither host has anodebinary, so the helper correctly refused before copying or running anything and printed an install instruction. It did not, and could not, run real plan logic on either box..../model) matches the two examples in the brief but hasn't been run against the real layout on either box./proc/*/fd+ cmdline process-in-use check only works meaningfully on Linux; degrades tochecked:falseoff Linux (this dev machine is macOS) rather than silently reporting idle.docker inspect'sMounts[].Sourceformat was assumed from documented docker behavior, not confirmed against the real vLLM/docker-compose setup on either box.~/models/*and the HF cache has been inspected on either box; the test fixture constructs a synthetic one viafs.linkSync.🤖 Generated with Claude Code