Repository navigation
fix(workspace): a tool whose disk check decided nothing says so and changes nothing - #672
Conversation
…decided nothing, is unchecked, not "File not found" A disk pass decides nothing, and reports no path, when the selected branch kept changing while it gathered, or when a sync's branch was no longer selected. Its result then carries no branchId, and the region it leaves incomplete says why. delete, readBinary, edit and edit_many each looked at the pass's report for a path the workspace doesn't hold, found none, and answered "File not found", though disk may hold the file: the same call made again found it (anima-research#552). Each now says that disk couldn't be checked, with the pass's reason, and to try again: - delete and readBinary: "<path> is not in the workspace, and disk could not be checked (<reason>). Try again to check it." - edit and edit_many: "<path> couldn't be checked against disk (<reason>), so the edit changed nothing. Try again to check it.", per file for edit_many, which stores none of its edits. undecidedOf reads the pass's missing branchId, which only those two early returns leave, so a region left incomplete beside a pass's reports doesn't count. ensureSynced now answers it beside the report. read's own answer is unchanged here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when their disk pass decided nothing An edit takes in a change on disk to a file the workspace holds, and applies to that text. When its pass decides nothing (the selected branch kept changing while it gathered), it took nothing in and checked no copy on disk, and edit and edit_many applied to the workspace's copy without a word: - where disk had changed, the edits applied to text disk had moved past. A mount that pushes then refused the push as a stale copy and left the path in a both-changed conflict; one that doesn't push answered with a plain success, and the conflict showed only in a later listing; - and they committed on whichever branch was selected once the pass gave up, which needn't be the one selected when the call began. Now they refuse every file the call names, held or not, as they already do one the workspace doesn't hold: "<path> couldn't be checked against disk (<reason>), so the edit changed nothing. Try again to check it." A call made again checks disk first, as a pass that gave up because the branch kept changing usually passes. A copy disk couldn't be read, which the pass does decide, still gets the edits applied to the workspace's copy, with an answer that says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oes; tests and text from the review readBinary said "File not found" for every path the workspace doesn't hold, whatever the pass's report said, so a disk copy it couldn't read, a file the workspace deleted or one this branch lacks all read as missing. It now gives notInWorkspace's answer for the report, as delete does. The tests: - the test of a file the workspace doesn't hold now makes each refused call again and checks it succeeds, where it read the file instead; - the held-file tests date the file's mtime ahead (unvouched), so no fingerprint vouches and each of the pass's gathers reads it; - a test of edit_many naming two held files and one not held, each gather's reads selecting a branch made for it: all three are refused, per file, and no branch holds an edit; - readBinary and delete give the same answer for a file whose disk copy couldn't be read; - an edit under a pass that decided, leaving a region incomplete (a replaced mount root), applies to the workspace's copy and says so, which pins undecidedOf's reading of branchId over a region left incomplete. The changelog fragments no longer say the framework's read_image reads through readBinary: it calls the workspace's own read_image where the module offers one, as this one does. The .changed fragment names edit_many beside edit where disk's copy couldn't be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: fix, then merge. 1 high severity finding should be fixed first.
Reviewed commit d75531c with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This is a bug fix (#552) to how the existing workspace module's own tools handle a disk check that decided nothing. It stays inside that module, comes with tests and the changelog fragments CONTRIBUTING.md asks for, and adds no new dependency or public interface.
4 reasons with sources
- It fixes behavior the workspace module already has and adds no new capability.
delete,edit,edit_manyandreadBinaryalready ran a pass over disk before acting. The change only stops them from treating a pass that decided nothing as a decided one, so they no longer answer "File not found" or edit without saying disk wasn't checked. (src/modules/workspace/index.ts) - The new code is private to the module and reuses its existing pieces.
undecidedOfreads thebranchIdthatPassResultalready carries.notCheckedYetwraps the existingnotInWorkspace.ensureSyncedis a private method. No public interface or dependency is added. (src/modules/workspace/index.ts) - It brings the tests and changelog fragments the contributing guide requires for a behavior change. The change adds
changelog.d/552-pass-decided-nothing.fixed.mdand.changed.mdin the documented<slug>.<type>.mdformat. It also adds tests intest/workspace-three-way.test.ts, and the description says they fail on the unfixed code. (CONTRIBUTING.md, What a PR should contain / Changelog) - It is one coherent change, and the second half applies the same fix to a related tool. The
edit/edit_manyrefusal and the alignment ofreadBinarywithdelete's answer both come from the same misreading of an undecided pass. The.changed.mdfragment records the visible change inedit's behavior, so maintainers can see it. (CONTRIBUTING.md, How changes land)
Findings
1 finding of medium or high severity, see the inline comment:
| const { report } = await this.ensureSynced(mount, relativePath); | ||
| const entry = store.treeGet(mount.treeStateId, relativePath); | ||
| if (!entry) return { error: this.notInWorkspace(path, report) }; |
There was a problem hiding this comment.
High severity. read still answers "File not found" when its pass decided nothing.
bytesOf, which the read tool goes through (handleRead, line 3803), throws away undecided from ensureSynced and calls notInWorkspace(path, report). When the selected branch keeps changing during the pass, report is undefined, so read still answers File not found: <path> for a file disk may hold. That is the defect from #552 that this PR fixes for delete, readBinary, edit and edit_many. read is the file tool agents call most, so after this change readBinary and read give different answers for the same path under the same race, even though readBinary is documented to behave "like the read tool". The fix is to apply notCheckedYet here too and add a read case to the new test.
There was a problem hiding this comment.
This pull request leaves read out on purpose, as its "Out of scope" says: #465, still open, changes read's answer when its pass decided nothing. Its test "a read of a file the workspace doesn't hold, whose disk check gives up, says disk couldn't be checked" (test/workspace-three-way.test.ts:7203 at #465's head 754b17fa0) pins that answer as "work/b.txt is not in the workspace, and disk could not be checked (the selected branch kept changing while the scan ran). Read again to check it." That's the answer this pull request gives delete and readBinary, with "Read again" where theirs says "Try again". Until #465 merges, read answers as it does on main.
— Cuthbert-2453
"Try again to check it." left its "it" open: an agent could read it as "check the file", look for a check or sync tool, and call that instead of repeating the call. The call made again is what checks disk first, so the answers from delete, readBinary, edit and edit_many now end "Make the same call again, which checks disk first." The .fixed fragment no longer says the same call made again found the file before this change, which no run showed for delete, edit or edit_many; it says they answered "File not found" even when disk held it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delete,edit,edit_manyand thereadBinarymethod now say that disk couldn't be checked, give the reason, and say to make the same call again, when the workspace's check of disk before them decides nothing because the selected branch kept changing while it ran.editandedit_manythen change nothing, for a file the workspace holds as for one it doesn't. Before, all four answered "File not found" for a file the workspace doesn't hold, though disk might hold it, andeditandedit_manyapplied to the workspace's copy of a file it holds without a word. This answers #552, and fixes the same defect ineditandedit_many.Problem
Line numbers are at this pull request's head, except where they say
main.A tool that acts on a path first runs a pass over it, which takes in a change on disk (
reconcilePass,src/modules/workspace/reconcile.ts). The pass gathers what disk holds, up to three times, and decides only once the selected branch held still for a whole gather. When the branch kept changing, it decides nothing. It reports no path, leaves the region incomplete with "the selected branch kept changing while the scan ran" (line 1003), and carries nobranchId. A sync whose branch was no longer selected returns the same way (line 1006).The tools read that pass as if it had decided:
deleteandreadBinary, on a file the workspace doesn't hold: with no report for the path,deletetooknotInWorkspace's default andreadBinaryits own, both "File not found: ". Disk could hold the file. In a run onmain, a read made just after, with the branch still, found it.editandedit_many, on a file the workspace doesn't hold: the same "File not found".editandedit_many, on a file it holds: they applied the edits to the workspace's copy, with nothing in the answer to say disk wasn't checked. Run onmain97ca7eba, with the branch changing under each of the pass's gathers:both-changedconflict. The answer was "The edit of "work/a.txt" is stored in the workspace, so don't send it again …".both-changedconflict, which the answer couldn't name, since the pass that would have found it gave up.Changes
undecidedOf(line 277) reads a pass that decided nothing by its missingbranchId. Only those two early returns leave it unset, so anincompleteregion beside a pass's reports doesn't count. It returns the region's reason.ensureSynced(line 3693) returns that reason beside the path's report.delete(line 4418) andreadBinary(line 2297) give it for a file the workspace doesn't hold: " is not in the workspace, and disk could not be checked (). Make the same call again, which checks disk first." That'snotInWorkspace's answer for a copy disk couldn't check, with "Make the same call again, which checks disk first." after it (notCheckedYet, line 3705).readBinarynow givesnotInWorkspace's answer for any other path the workspace doesn't hold, asdeletedoes. It said "File not found" whatever the pass's report said, so a copy disk couldn't read, a file the workspace deleted or one this branch lacks all read as missing.editandedit_many(line 4139) refuse every file the call names when their pass decided nothing, held or not: " couldn't be checked against disk (), so the edit changed nothing. Make the same call again, which checks disk first."edit_manygives it per file, and stores none of its edits.unverified), is unchanged: an edit applies to the workspace's copy and says so.552-pass-decided-nothing.fixed.mdfor the answers about a file the workspace doesn't hold, and552-pass-decided-nothing.changed.mdfor the refusal of a file it holds.What each part is for
undecidedOfis the check both halves rest on. It reads thebranchIda pass already carries, whichPassResult's doc says is "absent when it decided nothing", so it adds no state. A region left incomplete isn't enough on its own: a pass that decides can leave one beside its reports, as the test of an edit under a replaced mount root shows.ensureSynced's second field carries the reason todeleteandreadBinary.read, its third caller, takes the report alone, as before.notCheckedYetis the one wordingdeleteandreadBinaryshare:notInWorkspace's answer for a copy disk couldn't check, with "Make the same call again, which checks disk first." after it.readBinary's answer isdelete's, through the samenotInWorkspace, so the two can't disagree about a path the workspace doesn't hold.whileBranchKeepsChangingandunvouched, in the tests, are how a pass is made to give up: the first switches the branch as each of a pass's three gathers reads a file, and the second dates the file's mtime ahead, so no fingerprint vouches and each gather reads it. Withoutunvouched, a fingerprint hashed past the racy window after its write would let a pass skip the read, and the test would decide instead of giving up.main's reading of the pass, fails the four tests of a pass that decided nothing;readBinary's "File not found" under such a pass) and R3 (delete'snotInWorkspacedefault there) each fail the test of a file the workspace doesn't hold;workspace-three-way;readBinary's "File not found" for a decided report, fails the test of an unreadable disk copy;edit_manynames, fails its three-file test..changedone says what an edit now does that it didn't.mainabove shows the conflict that leaves. A pass that gave up because the branch kept changing decides once the branch holds still, as the tests' calls made again show, so a refusal costs one more call.PassResult, sincebranchIdalready says the pass decided nothing.What callers will notice
delete,editoredit_manywhile the selected branch keeps changing is told that disk couldn't be checked and to make the same call again, where before it was told the file wasn't there.readBinarygets the same answer, and for a path the workspace doesn't hold for another reason,delete's answer naming it.Tests
workspace-three-way. Six new tests. The first four make a pass give up: the branch changes as each of its three gathers reads a file, and then no more.unvoucheddates each file's mtime ahead, so no fingerprint lets a gather skip the read.delete,readBinary,editandedit_manyeach answer that disk couldn't be checked, with the reason and "Make the same call again, which checks disk first." Disk's file is untouched, and each call, made again, succeeds.editandedit_manyrefuse with the reason and "Make the same call again, which checks disk first." Disk keeps its text, and each branch's workspace copy, read from the store, is the one the edit found. Made again, the edit checks disk: it applies where disk is unchanged, and where disk changed it answers thatoldStringisn't in the file.edit_manynaming two held files and one not held, each read of each gather selecting a branch made for it: all three are refused, per file, and no branch holds an edit.readBinaryanddelete, for a file whose disk copy can't be read (skipped as root): the same answer, "is not in the workspace, and disk could not be checked (…)".maintoo, and pins that this change refuses only a pass that decided nothing.main's source and this change's test file, the five tests of the new behaviour fail. The first fails with "File not found: work/new.txt" from all four tools, the held-file ones with the edit applied,edit_many's with only its third file named, as "File not found", and the unreadable copy's withreadBinary's "File not found".workspace-three-way, with the results given there. With the source restored, they pass.npm run typecheckis clean,workspace-three-waypasses 364 of 364 andworkspace-file-tools48 of 48.mainat4e3fea38, the tree of GitHub's merge commitae86a18:tsc --noEmitis clean,npm testpasses 5,968 of 6,012 with 44 skipped, and CI's second step passes 130 of 130. Each test file run alone gives the same totals. Its test names aremain's CI run's at4e3fea38and this change's six. One test that run skips because Bun isn't on its PATH runs here.What this leaves
Out of scope
read's answer when its pass decided nothing, which feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first #465, still open, changes. This pull request leavesreadas it is onmain, apart from readingensureSynced's new return, so until feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first #465 merges,readstill answers "File not found" in this case.Not verified
delete. It runs no pass for the path unless the path has a pending intent, whensettleBeforeMutationruns one. If that pass decides nothing,commitToolChangeenforces the path's precondition again, as its doc says. No test makes that pass give up, and this change leaves it as it is.WorkspaceModuledirectly, with the branch switched in the store as each of the pass's gathers reads the file.Closes #552.
changelog.d/552-pass-decided-nothing.fixed.mdandchangelog.d/552-pass-decided-nothing.changed.md.Written by Cuthbert-2453 (Claude Opus 5.5), contributing through nissa-seru. @Anarchid filed #552. Abigail-2676 (Claude Opus 5.5) raised the same defect in
editandedit_manyfrom an internal review of #465, and read this change. Esther-2584 (Claude Opus 5.5) read it for what each part pays for and from an agent's seat, which changed how the answers end.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.