Skip to content

Track written-file identity (device + inode) for reuse - #131

Merged
grimicorn merged 1 commit into
mainfrom
agent/track-written-file-identity
Aug 27, 2026
Merged

Track written-file identity (device + inode) for reuse#131
grimicorn merged 1 commit into
mainfrom
agent/track-written-file-identity

Conversation

@grimicorn-agent

@grimicorn-agent grimicorn-agent commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

What & why

Closes #124.

writeMarkdown reuses a record's already-written file across autoSync passes (so a record whose server-side settle failed and is re-fetched as still-pending doesn't drop a fresh <slug>-2.md, <slug>-3.md duplicate every pass). Reuse eligibility previously checked only existence + type of the tracked path. That left one data-loss edge under autoDelete: if a record's own file was deleted and a different regular file was dropped at the same path between passes, reuse settled — and under autoDelete, deleted server-side — the record against a file that is not its content. The record's content was then gone from the server and never persisted locally.

This tracks each written file's identity (deviceId + inode) alongside the path and content hash. On a later pass, reuse is refused when the file at the tracked path is no longer the one we wrote, so the record is written fresh (suffix) / clobbers under overwrite / stays safely pending under skip instead of being settled against an unrelated file.

Key decisions

  • deviceId + inode, stored as bigint. This is the OS's own file identity (what hardlink detection and find -samefile use). deviceId disambiguates inode numbers that are only unique within one filesystem (a vault on an external/network mount). bigint (via lstat's bigint: true) preserves 64-bit inode/device ids (Btrfs, Windows file indexes, some network FSes) that would otherwise lose their low bits rounding through a JS double and false-match a different file.
  • Deliberately not mtime or birthtime. mtime moves on every in-place vault edit — which must not count as a different file (that edit is a first-class supported case, preserved by the existing content-hash check). birthtime is unreliable cross-platform: libuv aliases it to ctime on Linux without statx (containers), and ctime also moves on edits — so matching on it would reintroduce the per-pass duplicate bug on those hosts. inode is stable across in-place edits on every platform.
  • Narrows, does not absolutely eliminate. A filesystem that recycles a just-freed inode number for the replacement file (ext4, APFS) can still produce a false match, but only if the exact freed inode is reissued at the same path between two passes of one process — an extreme corner next to the common replace-with-new-inode case this rejects. Documented honestly in the code.
  • Graceful degradation on a transient post-write stat error. The identity is read back right after the write. If that read fails (rare), the entry is still recorded but without an identity, and the reuse check degrades to the prior existing-regular-file test rather than dropping tracking (which would spawn a duplicate). A later pass whose stat succeeds re-arms the identity guard, so the Track written-file identity for reuse #124 window doesn't stay open for the record's lifetime.

Behaviour by strategy when a foreign file is found at the tracked path

  • suffix: reuse refused → record written to a fresh suffixed file (its content persisted), then re-tracked and reused on later passes.
  • overwrite: reuse refused, but the fresh overwrite still lands on the base path and replaces the foreign file with the record's own content (the strategy the user opted into) — no data loss.
  • skip: reuse refused and skip won't clobber, so the write is a no-op and the record is left safely pending on the server (skip's normal occupied-slot contract). It recovers once the foreign file is gone.

Tests

Unit tests in tests/libs/markdown.test.ts cover: a different file (new inode) dropped at the path is refused and the record recovers onto a fresh file; a matching inode on a different device is refused; an in-place vault edit (same device+inode, moved mtime) is still reused with the edit preserved; identity unchanged reuses without a duplicate; the on-disk identity is stored; a transient post-write stat failure tracks without identity, reuses via the fallback, and re-arms the guard; and skip/overwrite behaviour under a foreign file. The reuse mock now models per-path device+inode identity.

Viewable

CLI behaviour (no UI): exercised via the writeMarkdown reuse path used by sync/get.

Verification

npm run lint, npm run typecheck, npm run build (tsc + tsc-alias), and npm run test:ci (707 tests) all pass.

Follow-up suggestions

  • Capture written-file identity from the created fd — Replace the write path's writeFileSync + stat-after-write with openSync(path, 'wx') + fstatSync(fd) + writeSync(fd) so the identity comes from the exact inode created, closing the narrow stat-after-write replacement race this PR only narrows. (suggested: P4, effort: M, evidence: src/libs/markdown.ts writers + readRegularFileIdentity)

@grimicorn-agent

Copy link
Copy Markdown
Collaborator Author

Independent code review trail

Ran the independent Opus reviewer in a loop. Each round surfaced real issues, so it went to four rounds; all substantive findings are resolved.

Round 1

Round 2

  • birthtime aliased to ctime on Linux without statx — Valid. birthtime would break in-place edits in containers just like mtime. Fixed by dropping birthtime entirely and using deviceId + inode (the canonical file identity, stable across in-place edits on every platform).
  • inode needs the device id — Valid. Fixed — identity is now {deviceId, inode}.
  • Comments still said mtimeFixed.
  • skip/overwrite mismatch untestedFixed — added tests for both.

Round 3

  • dev/ino lose precision above 2^53 — Valid on 64-bit-inode filesystems (Btrfs, Windows, some NFS). Fixed — read with { bigint: true } and store/compare as bigint.
  • Comment overstates the guarantee (inode recycling)Fixed — comments now say this narrows, not eliminates, the window.
  • Dropping tracking on a failed stat reintroduces duplicatesFixed — identity is now optional; a failed post-write stat records the entry without an identity and the reuse check degrades to the existing-regular-file test instead of dropping tracking.
  • Stat runs before the cheap dir checkFixed — the pure-string output-directory guard now runs before the filesystem stat.
  • Two vacuous tests — Superseded by the round-4 fixes below.

Round 4

  • Identity never recovered after a transient failure — Valid and important: the no-rewrite suffix/skip reuse path never re-records identity, so a single early stat failure left the Track written-file identity for reuse #124 guard off for the record's lifetime. FixedresolveReusableWrittenState now adopts the now-readable identity when the stored one is unverified, re-arming the guard from the next pass. Test added asserting a foreign file is then refused.
  • Two added test comments inverted the actual contract, and the mock's "fail loud" TypeError was dead codeFixed — reverted the now-unnecessary mock additions (the always-set rememberWrittenState makes those tests pass on their real guards) and removed the swallowed TypeError.
  • skip test asserted the refusal but not the promised recoveryFixed — added a recovery pass (foreign file removed → record writes fresh and is re-tracked).
  • Post-write stat comment overstated "the file that actually landed"Fixed — comment now notes stat-after-write shares the same narrow replacement race as the reuse check (documentation, not a redesign; the reviewer agreed).

Skipped / not actioned

  • Closing the stat-after-write and inode-recycling races absolutely would require an openSync('wx') + fstatSync(fd) + writeSync(fd) rewrite of the write path. Out of scope for this issue and disproportionate to an extreme corner; documented honestly in the code instead.

Verification after the final round: npm run lint, npm run typecheck, npm run build, npm run test:ci (707 tests) all green.

@grimicorn-agent grimicorn-agent added the has-suggestions PR carries follow-up suggestions for the improvement digest label Aug 24, 2026
@grimicorn
grimicorn merged commit af464cf into main Aug 27, 2026
3 checks passed
@grimicorn
grimicorn deleted the agent/track-written-file-identity branch August 27, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

has-suggestions PR carries follow-up suggestions for the improvement digest

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track written-file identity for reuse

2 participants