fix(tools): key read tracking canonically and record the version a write wrote (#524, #528) - #539
Merged
Merged
Conversation
ReadTracker keyed a read on filepath.Clean of the path as spelled, while
the write lock, WriteTracker and undo store key on lockPathKey (symlinks
resolved, case folded where the volume folds case). A file read through
one spelling (a symlinked parent, macOS /tmp vs /private/tmp, a case
variant) and written through another had no read record at the write, so
changedSinceSessionRead failed open over a peer's change and strict mode
failed closed ("has not been read").
Record, Mtime, recorded, Hydrate and the persist sink now all use
lockPathKey, so every consumer — the write guards, strict mode,
edit_file's not-found diagnosis, and the persisted read_tracking rows —
agrees on one key. Rows written by an older daemon under spelled keys are
re-keyed on Hydrate; when two spellings of one file collide, the later
read wins regardless of store order. Key resolution runs outside the
tracker lock because it touches the filesystem. No internal/cli
production change is needed: both rehydration paths go through Hydrate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
…th (#528) After a successful write, WriteDeps.recordWritten stat'ed the path and hashed it again to refresh the session's read record. The per-path lock excludes only plumb's own writers, so an outside process that wrote the file between plumb's rename and that hash had its content recorded as this session's version. The session's next write then passed both "changed since you read it" and the same-mtime expected_mtime check over a change it never saw — the write-side twin of the read race fixed in #520. The write tracker's mtime (read_file's concurrent-edit note, file_status's last writer) had the same hole. safeWrite now returns the published version in writeResult.written: the SHA-256 of the bytes it wrote and the mtime from an fstat of the staged file after its last write and fsync. The rename moves that inode, mtime included, so this is the target's version when the rename lands. recordWritten takes that snapshot and records it into both trackers; it no longer touches the path. Every caller passes what it wrote: write_file, edit_file (incl. apply_partial), find_replace, copy_file, transaction_apply (and its verified rollback), rename_symbol and move_symbol (via their plans), the symbol-edit tools, undo_edit, and the fail_on_new_errors revert. rename_file writes no bytes, so it snapshots the source through one descriptor (readSnapshot) before the rename and records that: any later change at the destination differs from it, so the guards refuse rather than trust it. If the source cannot be read, the version is unknown and recordWritten records the write but no read state, instead of guessing. An fstat failure on the staged file fails the write before the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
…rename (#528) The #528 tests pinned only the read record. Add the write tracker's half (read_file must still warn that the file changed after plumb's write when an outsider lands in the window), and the unreadable-source rename: the move is recorded as a write but no read state is invented for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
…ted rows by keying, stat the closed temp B1 (#528, blocking): edit_file's reply printed `mtime:` from a fresh os.Stat of the path, taken after the post-write diagnostics wait (seconds with await_diagnostics). An outside write in that wait handed the agent the outsider's mtime; passed back as expected_mtime it matched the file, and changedAtSameMtime could not second-guess it because the recorded read sits at plumb's mtime, so the next write clobbered the change. The reply, and apply_partial's header, now print the version plumb wrote (writeResult.written) — the same one recordWritten recorded. No other write tool's reply prints a post-write mtime or sha; the remaining current-mtime lines are in refusals and file_status, which report the file's present state by design. N1 (#524): Hydrate chose between an older daemon's spelled row and a canonical row by file mtime. mtime-preserving tools (cp -p, rsync -t) move mtime backwards, so the stale spelled read could win and the guard then refused a file the session had read, on every restart. A row stored under its canonical key now outranks a spelled one (only an older daemon writes those); file mtime still decides between two rows of one kind. N2 (#528): stagedSnapshot took the mtime by fstat on the still-open temp file. Filesystems that stamp mtime at close (SMB, WSL drvfs) would record an mtime trailing the published one and refuse the session's own next write. It now Lstats the staged name after Close and before the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: wild-vista
…ne (#528) mutation_test showed the apply_partial half of the reply fix unpinned: reverting its header to a re-stat of the path survived, because the header was rendered before the first post-write hook, so no test could land an outside write where the old re-stat would see it. Render the header after the post-write pipeline, as edit_file's ordinary reply is; output order is unchanged. Both replies now face the same window, and TestEditFile_ReplyMtimeIsTheWrittenVersion pins both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: wild-vista
golimpio
approved these changes
Sep 30, 2026
golimpio
left a comment
Contributor
There was a problem hiding this comment.
Two independent review rounds. Round 1's blocker (edit_file's reply mtime re-read the path) is fixed, and round 2 found nothing blocking. Every fix is proven by mutation.
atlas-from-plumb
enabled auto-merge (rebase)
September 30, 2026 21:25
atlas-from-plumb
added a commit
that referenced
this pull request
Sep 30, 2026
…ted rows by keying, stat the closed temp B1 (#528, blocking): edit_file's reply printed `mtime:` from a fresh os.Stat of the path, taken after the post-write diagnostics wait (seconds with await_diagnostics). An outside write in that wait handed the agent the outsider's mtime; passed back as expected_mtime it matched the file, and changedAtSameMtime could not second-guess it because the recorded read sits at plumb's mtime, so the next write clobbered the change. The reply, and apply_partial's header, now print the version plumb wrote (writeResult.written) — the same one recordWritten recorded. No other write tool's reply prints a post-write mtime or sha; the remaining current-mtime lines are in refusals and file_status, which report the file's present state by design. N1 (#524): Hydrate chose between an older daemon's spelled row and a canonical row by file mtime. mtime-preserving tools (cp -p, rsync -t) move mtime backwards, so the stale spelled read could win and the guard then refused a file the session had read, on every restart. A row stored under its canonical key now outranks a spelled one (only an older daemon writes those); file mtime still decides between two rows of one kind. N2 (#528): stagedSnapshot took the mtime by fstat on the still-open temp file. Filesystems that stamp mtime at close (SMB, WSL drvfs) would record an mtime trailing the published one and refuse the session's own next write. It now Lstats the staged name after Close and before the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: wild-vista
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.
Fixes #524
Fixes #528
Two holes in the session's read record, the state that the "changed since you read it" guard, strict mode and the same-mtime
expected_mtimecheck rely on.#524: a read recorded under one spelling was invisible to a write through another
Defect. Suppose a file is read through one spelling, a peer then changes it, and the session writes it through another spelling. The spellings can differ by a symlinked parent, by macOS
/tmpversus/private/tmp, or by case on a case-insensitive volume.write_file's default guard let the overwrite through and discarded the peer's change. Strict mode refused the same flow with "has not been read".Cause.
ReadTrackerkeyed entries onfilepath.Cleanof the path as spelled. The write lock,WriteTrackerand the undo store key onlockPathKey, which resolves symlinks and folds case where the volume folds it. So at the write the lookup missed, andchangedSinceSessionReadreads "never read" as "not stale". The persistedread_tracking.pathrows had the same spelling dependence.Fix.
Record,Mtime,recorded,Hydrateand the persist sink all key onlockPathKey.lockPathKeytouches the filesystem, so it is resolved outside the tracker lock.Hydratere-keys every row, so rows persisted by an older daemon under spelled paths come back canonical. When two spellings of one file collide, the later read wins whatever order the store returns them in: that is the newest version the session is known to have seen.internal/clineeds no production change, because both rehydration paths (rehydrateReads,rehydrateReadsForAgent) and the shard seeding go throughHydrate. Its only change is one new test file.#528: after a write, plumb recorded whatever the path held by then
Defect. If an outside process writes the file between plumb's rename and the post-write bookkeeping, its content is recorded as this session's version. The session's next write then passes both guards over a change it never saw.
Cause.
WriteDeps.recordWrittenranstatand hashed the path again after the write. The per-path lock excludes only plumb's own writers.WriteTracker.Recordalso ranstaton the path, soread_file's concurrent-edit note andfile_status's last writer shared the hole.Fix.
safeWritenow returnswriteResult.written, built by a newstagedSnapshot. It holds the SHA-256 of the bytes written and the mtime from anfstatof the staged temp file, taken after its last write and fsync and before the rename. A rename moves the inode with its mtime, so this is the target's version at the moment the rename lands. Anfstatfailure fails the write before the rename.recordWritten(ctx, path, v fileSnapshot)recordsvinto both trackers and no longer touches the path.Every caller passes the version it wrote:
write_fileedit_file, includingapply_partialfind_replacecopy_filetransaction_apply, including its verifiedfail_on_new_errorsrollbackrename_symbolandmove_symbol, through their plansundo_editfail_on_new_errorsrevertWhere plumb holds no bytes:
rename_file. It moves an inode, so before the rename it takes a snapshot of the source through one descriptor (readSnapshot) and records that version. A later change at the destination differs from it, so the guards refuse rather than trust it. If the source can't be read, the version is unknown.recordWrittenthen records the write but invents no read state: a zero-mtime record would make every later write look stale.Verification
origin/mainfor the stated reason:internal/tools/read_tracker_alias_test.go. The guard fails open in both directions (read through the alias and write through the real path, and the reverse). The case-variant spelling also fails open, and strict mode says "has not been read". Hydrating a spelled row misses the canonical path.internal/cli/conn_persist_alias_test.go. A pre-fix spelledread_trackingrow fails to rehydrate for the canonical path after a restart. Confirmed red withread_tracker.goreverted.internal/tools/write_record_race_test.go. An outsider write lands inside the window through the adapter hook or LSP notification, which run before the bookkeeping. It uses two modes, mtime advanced and mtime preserved, the latter thecp -p/same-tick case where only the SHA can tell. The next unguardedwrite_filewas not refused forwrite_file,edit_file,transaction_applyorrename_file.read_file's concurrent-edit note still fires after an outsider.GOWORK=off GOTMPDIR=$PWD/.testcache go test ./internal/tools/... ./internal/cli/... ./internal/sessionstate/... ./internal/paths/... -count=1passes.go test -raceover the affectedinternal/toolstests, and thePersist|Shard|Repin|LogicalAgenttests ininternal/cli, passes.golangci-lint run ./internal/tools/... ./internal/cli/...(v2.13.2) reports 0 issues.make check-size check-brief check-changelogpasses.mutation_testwas refused for over 30 minutes because another agent's run held the daemon's mutation slot, so these were applied by a script on the committed tree.Record,Mtimeandrecordedeach keyed on the spelling, three mutants.Hydratekept the spelled key.Hydratecollision: the last row wins.recordWrittenre-reads the path for the read tracker.recordWrittenrunsstaton the path for the write tracker.stagedSnapshothashes the wrong bytes.stagedSnapshot's mtime is off by 1 ns.rename_filere-reads the destination.transaction_applydrops the written version.edit_fileandwrite_filere-read the path, one mutant each.safeWriteSiblingbranch, which needs a second filesystem, and thefstatfailure of a staged temp file.Review round 1 (folded in)
edit_file's reply mtime.mtime:line was a freshos.Statof the path, taken after the post-write diagnostics wait.expected_mtimethat let its next write clobber the change.apply_partial's header now printwriteResult.written, the version that was also recorded.apply_partial's header is now rendered after its post-write pipeline, as the ordinary reply is, so one test pins both. Output order is unchanged.file_status, which report the file's present state by design.write_reply_version_test.go. An outsider lands before the reply, the reply shows plumb's mtime, and a write guarded by it is refused, for both replies and both outsider modes. The positive control checks the reply mtime round-trips and a peer write afterwards is refused.Hydrate.cp -pandrsync -tmove the mtime backwards, and the stale row used to win.TestReadTracker_HydrateCanonicalRowOutranksSpelledRow.stagedSnapshotnow callsLstaton the staged name afterCloseand before the rename, instead offstaton the open descriptor. Some filesystems (SMB, drvfs) stamp the mtime at close.edit_filemtime-advances and hydrate cases. Theapply_partialcase was not red then, because its header was rendered before any hook could run; that is why the header now renders later.go testover tools, cli, sessionstate and paths passes, as do-race, the integration-tagged tools tests, lint (0 issues) andmake check-size check-brief check-changelog.mutation_test: 6 of 6 mutants were killed.edit_filereply re-stats the path, andapply_partialheader re-stats the path. The header mutant first SURVIVED, which led to the render-order change, and was then killed.🤖 Generated with Claude Code