Skip to content

fix(workspace): a tool whose disk check decided nothing says so and changes nothing - #672

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/f552-pass-gave-up
Oct 22, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/f552-pass-gave-up

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

delete, edit, edit_many and the readBinary method 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. edit and edit_many then 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, and edit and edit_many applied to the workspace's copy of a file it holds without a word. This answers #552, and fixes the same defect in edit and edit_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 no branchId. A sync whose branch was no longer selected returns the same way (line 1006).

The tools read that pass as if it had decided:

  • delete and readBinary, on a file the workspace doesn't hold: with no report for the path, delete took notInWorkspace's default and readBinary its own, both "File not found: ". Disk could hold the file. In a run on main, a read made just after, with the branch still, found it.
  • edit and edit_many, on a file the workspace doesn't hold: the same "File not found".
  • edit and edit_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 on main 97ca7eba, with the branch changing under each of the pass's gathers:
    • Disk unchanged: the edit applied, and on a mount that pushes, it pushed.
    • Disk changed, a mount that pushes: the edit was stored on the old text. The push then refused it as a stale copy, and the path was left in a both-changed conflict. The answer was "The edit of "work/a.txt" is stored in the workspace, so don't send it again …".
    • Disk changed, a mount that doesn't push: a plain success. A later listing showed the both-changed conflict, which the answer couldn't name, since the pass that would have found it gave up.
    • Either way, the edit was committed on whichever branch was selected when the pass gave up, not necessarily the one selected when the call began.

Changes

  • undecidedOf (line 277) reads a pass that decided nothing by its missing branchId. Only those two early returns leave it unset, so an incomplete region 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) and readBinary (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's notInWorkspace's answer for a copy disk couldn't check, with "Make the same call again, which checks disk first." after it (notCheckedYet, line 3705).
  • readBinary now gives notInWorkspace's answer for any other path the workspace doesn't hold, as delete does. 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.
  • edit and edit_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_many gives it per file, and stores none of its edits.
  • A copy disk couldn't read, which the pass does decide (unverified), is unchanged: an edit applies to the workspace's copy and says so.
  • Two changelog fragments: 552-pass-decided-nothing.fixed.md for the answers about a file the workspace doesn't hold, and 552-pass-decided-nothing.changed.md for the refusal of a file it holds.

What each part is for

  • undecidedOf is the check both halves rest on. It reads the branchId a pass already carries, which PassResult'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 to delete and readBinary. read, its third caller, takes the report alone, as before.
  • notCheckedYet is the one wording delete and readBinary share: notInWorkspace's answer for a copy disk couldn't check, with "Make the same call again, which checks disk first." after it.
  • The edit plan's check runs before a file's entry is read, so one check covers a file the workspace holds and one it doesn't.
  • readBinary's answer is delete's, through the same notInWorkspace, so the two can't disagree about a path the workspace doesn't hold.
  • whileBranchKeepsChanging and unvouched, 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. Without unvouched, 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.
  • The tests pin it from both sides. Each of nine removals fails at least one of the six new tests, and with the source restored nothing fails:
    • R1, main's reading of the pass, fails the four tests of a pass that decided nothing;
    • R2 (readBinary's "File not found" under such a pass) and R3 (delete's notInWorkspace default there) each fail the test of a file the workspace doesn't hold;
    • R4, refusing only a file the workspace doesn't hold, fails the three tests of a held file;
    • R5, never refusing, and R6, leaving out "Make the same call again, which checks disk first.", fail all four;
    • R7, reading any region left incomplete as a pass that decided nothing, fails the test of a replaced mount root, run against the whole of workspace-three-way;
    • R8, readBinary's "File not found" for a decided report, fails the test of an unreadable disk copy;
    • R9, refusing only the first file edit_many names, fails its three-file test.
  • The changelog fragments are the repository's required entries. The .changed one says what an edit now does that it didn't.
  • Cut:
    • Applying to the workspace's copy and saying so, for a file the workspace holds. That's what an edit does where disk's copy couldn't be read. Here the pass took nothing in, so the edit can only apply to text disk may have moved past, and on a branch the switching left selected. Where disk had changed, the run on main above 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.
    • A new field on PassResult, since branchId already says the pass decided nothing.

What callers will notice

  • An agent calling delete, edit or edit_many while 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.
  • A module or host calling readBinary gets the same answer, and for a path the workspace doesn't hold for another reason, delete's answer naming it.
  • An agent editing a file the workspace holds in that moment gets a refusal and makes the call again, at the cost of one more call. Before, the edit applied to the workspace's copy. Where disk had changed, that left the path in a conflict, on whichever branch the switching left selected.
  • No tool description changes.

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. unvouched dates each file's mtime ahead, so no fingerprint lets a gather skip the read.
    • A file the workspace doesn't hold, which disk has: delete, readBinary, edit and edit_many each 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.
    • A file the workspace holds, on a mount that pushes and on one that doesn't, with disk's copy unchanged and changed: edit and edit_many refuse 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 that oldString isn't in the file.
    • edit_many naming 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.
    • readBinary and delete, 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 (…)".
    • An edit under a replaced mount root, where a pass decides each path as unchecked and leaves the root's region incomplete: it applies to the workspace's copy and says disk couldn't be checked. It passes on main too, and pins that this change refuses only a pass that decided nothing.
  • Red-first. With 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 with readBinary's "File not found".
  • Removals. Each of the nine in "What each part is for" ran against the six new tests and the two existing ones about a branch changing under a pass, and R7 against the whole of workspace-three-way, with the results given there. With the source restored, they pass.
  • Focused runs, on Linux with Node 24.22.0, at this head: npm run typecheck is clean, workspace-three-way passes 364 of 364 and workspace-file-tools 48 of 48.
  • Full check, on Linux with Node 24.22.0, on this head merged with main at 4e3fea38, the tree of GitHub's merge commit ae86a18: tsc --noEmit is clean, npm test passes 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 are main's CI run's at 4e3fea38 and this change's six. One test that run skips because Bun isn't on its PATH runs here.
  • Reviews. An internal review read the change, and this head carries the fixes it judged worth making here. The one it judged worth fixing outside this change is workspace: a watcher batch or catch-up scan whose pass decided nothing takes in no change on disk #673. Abigail-2676 read the first three commits, and Esther-2584 the fourth.

What this leaves

Out of scope

Not verified

  • A held path's delete. It runs no pass for the path unless the path has a pending intent, when settleBeforeMutation runs one. If that pass decides nothing, commitToolChange enforces the path's precondition again, as its doc says. No test makes that pass give up, and this change leaves it as it is.
  • Under a host. The tests drive WorkspaceModule directly, with the branch switched in the store as each of the pass's gathers reads the file.

Closes #552.


  • Changelog fragments added: changelog.d/552-pass-decided-nothing.fixed.md and changelog.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 edit and edit_many from 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-seru account.

Cuthbert-2453 and others added 3 commits October 21, 2026 14:49
…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>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_many and readBinary already 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. undecidedOf reads the branchId that PassResult already carries. notCheckedYet wraps the existing notInWorkspace. ensureSynced is 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.md and .changed.md in the documented <slug>.<type>.md format. It also adds tests in test/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_many refusal and the alignment of readBinary with delete's answer both come from the same misreading of an undecided pass. The .changed.md fragment records the visible change in edit's behavior, so maintainers can see it. (CONTRIBUTING.md, How changes land)

Findings

1 finding of medium or high severity, see the inline comment:

Comment on lines +3739 to 3741
const { report } = await this.ensureSynced(mount, relativePath);
const entry = store.treeGet(mount.treeStateId, relativePath);
if (!entry) return { error: this.notInWorkspace(path, report) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@nissa-seru
nissa-seru merged commit 31ff796 into anima-research:main Oct 22, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace delete and readBinary answer "File not found" when their disk pass gave up

1 participant