Skip to content

fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk - #456

Merged
nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-file-tools
Oct 17, 2026
Merged

nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-file-tools

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

TL;DR: the workspace's write, edit and delete now store exactly what they're given or refuse, and say so when a change is stored but disk didn't take it. read numbers the lines a file has, and an edit applies to the text disk holds now, saying which copy it used. Each case below changed what an agent's file held, or told it something untrue, while answering success.

Problem

On main (3adbdb86, and 119a8625, which changes only CI and CONTRIBUTING since), driving the workspace module through handleToolCall as a tool call reaches it (Node 24.22.0, Linux):

  • An edit's replacement wasn't written as given. edit replaced with String.replace, which reads $&, $$, $` and $' as patterns: editing "price: 10" with newString $$5, $&, $', $1 wrote "price: $5, 10, \n, $1", and replaceAll with $&$& doubled each match. Each answered success.
  • An edit of a file that isn't valid UTF-8 changed bytes outside the edit. It decoded with U+FFFD and wrote the whole text back: editing "au" in a Latin-1 "café au lait" also turned the é byte E9 into EF BF BD.
  • A lone UTF-16 surrogate in a write's content was written as U+FFFD, with a success receipt.
  • An empty oldString matched between every character: with replaceAll, "abc\n" became "XaXbXcX\nX". A replaceAll whose oldString wasn't in the file answered success and stored the file unchanged.
  • Inputs weren't checked against the tool's schema. An edit with no newString wrote the word "undefined". replaceAll: "false" counted as true. read numbered lines from an offset of 0, -1 or 1.5 as given.
  • A change stored but refused by disk read as a failure. On a mount with autoMaterialize, a write whose push disk refused answered "Wrote to Chronicle but failed to materialize …", though the workspace had stored it. Sending an edit again after that answer applied it twice. A throw after the change was stored answered only the error.
  • A refused push's way out named the wrong tool. Quoted under write, edit or delete, it said "pass force", an argument those tools don't take. For a deletion it left out applyDeletions, without which materialize unlinks nothing. A disk copy that couldn't be verified was offered force whatever kept it from being verified: over a FIFO, or a symlink the mount doesn't follow, a forced materialize answered "not written: not a regular file" or "not written: a symlink, which this mount does not follow", and left it as it was. A copy the workspace couldn't read was offered force with no word that a forced write needs the file writable, or a forced unlink its directory, neither of which the read's refusal shows. A path whose branch intent couldn't be read was told to fix it and materialize again, which is refused the same way until a sync of the path replaces the intent.
  • read counted a line the file doesn't have. "a\nb\n" read as three lines, the third empty, and an empty file as one. An offset past the end answered success with fromLine 10 and toLine 3.
  • An edit didn't look at disk. It read the workspace's stored copy, so after another writer changed the file, it edited the older copy, and on a pushing mount its push then failed as stale.
  • Smaller texts: grep refused an invalid pattern as "Invalid regex: (" with no reason, and a refusal thrown inside a tool began "Error: ". A push blocked by a directory at the path offered force, which can't write or unlink there. One blocked by a file where the path needs a directory named no way out.

Changes

All in src/modules/workspace/ (index.ts, reconcile.ts).

  • Exactly what was given, or a refusal (handleWrite, handleEdit, parsePath):
    • edit replaces through a replacer function, so the new text goes in as given.
    • edit refuses a file that isn't valid UTF-8 (isUtf8), changing nothing. Its refusal says that read shows each invalid byte sequence as U+FFFD, and that a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.
    • edit refuses an oldString that isn't in the file, replaceAll or not, and an empty oldString.
    • write refuses a lone surrogate in content, and edit in oldString or newString, before anything is read. Every tool refuses one in a path, in parsePath, since disk's name for the file is UTF-8.
  • Every tool's input is checked against its own schema before its handler runs (inputProblem, in handleToolCall): an object, each required field present, strings, booleans, and whole numbers. A typed field given as null, or a number given as a string, is refused with the field named, as read already refused them for offsetChars and limitChars. Minimums sit in INPUT_MINIMUMS, beside inputProblem: read's offset and limit at least 1, offsetChars 0 and limitChars 1, and grep's context lines 0. membrane's ToolParameter has no minimum. read's offset and limit, and grep's context fields, are now typed integer. read's own check of offsetChars and limitChars folds into this one, and its check that a read doesn't page by lines and characters at once stays.
  • A change stored but unfinished says so (storedButUnfinished). A write, edit or delete whose push to disk didn't complete, or that threw after its tree entry was set, answers an error. It leads with "The … of "path" is stored in the workspace, so don't send it again", then quotes the push's reason, which says what disk holds and the way out. A throw adds that ls shows the path's state. The answer carries no data: an error reaches the model as its text alone (buildStoredToolResultContent), and a code_execution script as Error: and that text (handleScriptToolCall), so the text says everything. commitToolChange now marks a removal committed as it does a set.
  • A refused push names materialize's way out (reconcile.ts): "materialize it with force" where it said "pass force", and, for a deletion, applyDeletions beside it. inTheWay says what's in the way when disk's shape blocks a path: a directory at it, or a file or symlink where its directory should be. It gives the way out for a write, and for a deletion it says that no push unlinks a directory. It answers for the unverified refusal and for a failed effect. Any other disk copy that can't be verified is told to fix it and materialize again, and force is named beside that only where a forced push is known to get past what kept the copy from being verified (forcedWay). For cannot open (EACCES), a file its permissions keep the workspace from reading, force is named on the condition it needs: "if disk lets the workspace write the file", or for a deletion "unlink the file", since a read's refusal shows neither. For a deletion, something that isn't a regular file is named force as it is, since unlinking removes it. A path whose branch intent can't be read is told to sync the path, which replaces the intent: decide() decides nothing over such an intent until a sync does, and no force is named for it. A sync replaces it only where it sees disk's copy, so where disk's copy can't be verified either, the reason names that first and says the sync works once it can be.
  • read and grep number the lines a file has (linesOf): a final line end ends the last line, and an empty file has no lines. An offset past the last line answers empty content with totalLines, no range, and a note saying where the file ends. An empty file read from line 1 answers totalLines: 0 with no note.
  • An edit applies to the text disk holds now. It first passes over its path, as a listing does, so a change on disk to a file the workspace holds comes in. That pass also settles a pending intent on the path, which settleBeforeMutation did there before.
    • What the answer says: when disk's copy had changed since the workspace last agreed with it, when disk had deleted the file, or when disk's copy couldn't be checked and the edit used the workspace's copy. Also when disk has no copy and nothing records whether it was deleted there: the edit uses the workspace's copy, and its commit makes the path the workspace's own change (commitToolChange marks it the store's), so the file goes back to disk, with the edit's own push on a mount that pushes or with a materialize of the path on one that doesn't. It did before too, unsaid; now a success says which. The note on which copy the edit used comes on a success, a miss and a stored-but-unfinished answer alike.
    • A file the workspace didn't hold is taken in with no note, since there's no older copy the agent could have read.
    • A draft whose disk copy also changed: the edit applies to the draft as before. A note names the conflict and both ways out on a refusal for what the edit found: a miss, a string found more than once, or a copy that isn't valid UTF-8. On a mount that doesn't push, a success names it too. On a mount that pushes, the push's own reason names it when the push stops there, and an edit whose push completed met no conflict, as when the edit made the draft agree with disk, so its success names none. A stored edit that couldn't finish leaves it to the push's reason, or to ls, which its answer points to.
    • write doesn't look at disk first, since it replaces the whole file. As before, it only settles a pending intent.
  • Smaller texts: grep's refusal gives the parser's reason, and the catch-all gives an error's message without "Error: ". ls's description names applyDeletions beside force for a path the workspace deleted.

What callers will notice

  • Agents: refusals where they used to get a silently wrong file; an answer that tells them a change is stored before they send it again, with a way out they can take; line counts that match the file; and edits that see another writer's change and say so. A disk copy that can't be verified is offered force only where a forced push is known to get past what kept it from being verified, and one the workspace can't read is offered it on the condition force needs. So one whose read failed otherwise, or that changed while it was read, is now told only to fix it and materialize again.
  • Calls that used to work: a typed field sent as null, or a number sent as a string, is refused with the field named, where some handlers read null as the field's default and did arithmetic on a numeric string.
  • Scripts: a stored change that didn't reach disk is an error whose text says so first, which a script receives, as it does any error, as Error: and that text. A read past the end has no fromLine or toLine, and its content is empty, as before, so a loop that pages until it reads nothing still stops. The refusal texts changed. Nothing in connectome-host 9bc96b14, chapterx c77a8932, discord-mcpl 4076d692, context-manager e8e8b214 or membrane 5c684a79 matches on the texts this change replaced in the workspace's answers ("Wrote to Chronicle", "did not unlink", "Invalid regex", "failed to materialize").
  • Hosts and recipes (connectome-host cited at 9bc96b14): nothing to change for a recipe that sets autoMaterialize as the boolean connectome-host declares it (src/recipe.ts:685). A tool's change now pushes only where autoMaterialize is true itself, as connectome-host's own check reads it (src/recipe.ts:2474); main read it for truthiness, so a mistyped string "true" or "false" pushed there and doesn't now. In connectome-host, the Web UI and the headless child's answer to the fleet's request-workspace-file read totalLines, fromLine and toLine with defaults (src/modules/web-ui-module.ts:2432-2471 and src/headless.ts:350-384), so an empty file shows as no lines, and a file ending in a line end shows its real count. The schemas' integer types change the tool definitions a model receives, so each agent's first request after the upgrade misses the prompt cache once.

Tests

On Linux, Node 24.22.0, installed with npm install as CI does (membrane 0.5.87, context-manager 0.14.1, chronicle 0.4.0):

  • The new tests: test/workspace-file-tools.test.ts, 35 cases, pass 35/35 at 83cc65ea, none skipped. This head changes only a comment since. The module's ten other test files pass beside them, none skipped, workspace-three-way 213/213 among them, where two expectations changed, for the new texts of a refused deletion and the interrupted reason. workspace-materialize-freshness's test that an unreadable but writable disk copy's refusal names the override passes unchanged, 9/9 for its file.
  • Red-first: against main 3af13bf4's index.ts and reconcile.ts, all 35 new cases fail.
  • main merged in: 1d5f4108 merges main 3af13bf4 into the branch. The counts above are at 83cc65ea, which builds on it, and tool-result-guard passed 124/124 at 1d5f4108. fix(workspace): refuse a glob brace form that can't be read, rather than hang or misread it #439's new workspace-glob-unclosed-brace test expected main's catch-all prefix, Error: , and its expected text drops it here, as every workspace refusal does now; its file passes 6/6. Before that line changed, a run of the same merge passed main's other changed tests that touch the workspace: failed-create-releases, prose-segments, save-recent-image and stop-at-tool-boundary.
  • Removals: each piece, removed or turned back alone in the built code, fails at least one case: 53 pieces at 80c432cd, then 17, 7, 4, 4 and 2 for the rounds after it, at 650487e6, 3e8536e3, 99f8c577, 2c8c9d7d and 83cc65ea. The pieces are: each refusal, input check and minimum; each stored-but-unfinished answer and its note, that it carries no data, and that a refused edit's says nothing of the file going back; the committed mark on a removal; the line rule in read and grep, and the past-the-end answer and its note; the edit's pass and each of its notes, the missing-copy note in each mount's words, the conflict note on each refusal, and no conflict note on a pushing mount's success among them; the reasons' materialize wording for a write and a deletion; inTheWay's reasons, at both call sites; forcedWay's two cases, the EACCES case's condition and the permission it names, and naming force for every note or for none; the unreadable intent's reason, with the sync as its one way out, and its naming disk's copy first where a sync can't see it; grep's reason; and the catch-all's text.
  • A forced push, as file modes bind a user: the tests force a write past a 0200 file and see one refused at a 000 file, force the unlink of an unreadable file, and check that a write over a symlink the mount doesn't follow isn't offered force. A forced materialize over that symlink, run at d10f4ae3, answered "not written: a symlink, which this mount does not follow" and left the link and its target as they were.
  • Internal review: run on 006b5329, whose 29 issues are judged; on its first fix round's diff, 006b5329..a1676bc8, whose 24 are; on the rounds after that, a1676bc8..d10f4ae3, Ogden-2688's run, whose 15 are; on the next, d10f4ae3..650487e6, his second, whose 9 are; on 650487e6..3e8536e3, his third, whose 11 are; and on 3e8536e3..99f8c577, his fourth, whose 9 are. 2c8c9d7d answers that sixth run, in the tests alone, 1d5f4108 merges main onto it, and 83cc65ea answers Greptile's review of 1d5f4108.
  • The suite, at 80c432cd merged onto main 119a8625 (47a18338), on Linux at loads of 59 to 77 on 12 CPUs. This head adds to 80c432cd the changelog's fix, five rounds in the workspace module and its tests, and main's move from 119a8625 to 3af13bf4, whose own runs are above; GitHub's CI runs the suite on the pull request. The typecheck and build are clean. Each test file run on its own on Node 24.22.0: 153 files and 2462 tests, all passing but one, below. main 3adbdb86, whose test files 119a8625 keeps, run the same way has 152 files and 2434 tests, and the only file whose count differs is the new one, of 28. CI's test step passes 2462 of 2462 on Node 20.20.2. On Node 24.22.0 it ran 2454 and failed one: --test-force-exit cut eight tests short under that load, each of which passes when its file runs on its own. CI's unforced step passes 105 of 105 on each Node.
  • Two tests failed in some runs, under that load:
    • code-execution's "abort() settles a running script and reclaims the interpreter" failed in CI's step on Node 24, its result without aborted, and passed with its file run on its own in the same check (50 of 50). It gives the interpreter 500 ms to start before it aborts.
    • tool-result-guard's "fix(guard): a withheld tool result says where its original is (#277) #281: batches behind one stalled write cost one deadline: no write starts until it settles" failed in 2 of 10 runs of its file on its own at 47a18338, one in the check above and one at a load of 70, and in none of 10 at main, 9 of them run alternately with the head's at loads of 40 to 72. It passed in CI's step on both Nodes, and its file passed 124 of 124 in runs of 650487e6, 3e8536e3 and 99f8c577, each merged onto main 119a8625, the first at a load of 9. The failure kept is "expected 2, actual 1", the count of writes after the third batch. Ogden-2688 read it as a race with load in main's test: once a batch's deadline, 50 ms here and counted from when the batch is queued, has passed, spillWithheld answers batch_deadline before any write (framework.ts:15073 at this head), and nothing this change touches runs before that check; parsePath's surrogate check runs after it. Run alone (--test-name-pattern) and pinned to one CPU beside six busy loops, the test failed 3 of its first 13 runs at d10f4ae3, each at an assertion expecting true, which one wasn't kept; the same run at main never started, since the test machine went down first. main has since fixed the test: test(guard): hold the clock still in three batch-deadline tests #407 (ff90a0f6) holds Date still in it and in two other batch-deadline tests, for that reason, and its message says this test and one other failed now and then under load, on main and on branches that didn't touch the guard. This head merges that fix in with main.

Not verified

  • Windows. GitHub's CI runs the suite on Linux and macOS with Node 20 and 24, and all four passed at 1d5f4108.
  • watch: 'on-agent-action' and 'always': the edit's pass over its path is the one listings make on every watch mode, and the tests run on 'never'.
  • Chronicle's treeSet and treeRemove are taken to change the tree only when they return. The committed mark is set right after each, and chronicle ships here as a native binary, so I didn't read its source.
  • A deletion meeting the interrupted conflict. Its reason takes the deletion's wording, as the no-evidence one does, which a test drives.
  • Whether a forced push gets past the notes force isn't named for, such as a read that failed otherwise. Runs show only that it doesn't get past a FIFO for a write or a symlink the mount doesn't follow. By reading, a forced write gets past "the file changed while it was read", where materializing again is named instead, since another writer is at the file; and on a mount that follows symlinks, a forced deletion unlinks a symlink to a directory, which isn't named force.
  • How a forced push treats a path whose branch intent can't be read: by reading, a forced write goes ahead, and a forced deletion is skipped with no entry in the answer. That's main's code, on a path only store damage reaches, and this change leaves it as it is.

Out of scope

What each part is for

The design this follows names each part's purpose and cost; this records only where building changed it.

  • Booleans are checked too, beyond the design's text and numbers. replaceAll: "false" counted as true. By reading, so would materialize's force: "false", which overwrites another writer's change, applyDeletions: "false", which deletes disk's copies, and sync's acceptRoot: "false", which takes a replaced root.
  • null and numeric strings are refused for every typed field, rather than read as absent here and there. One rule is simpler than each handler's leniency, and read already refused them for character paging.
  • Minimums sit in a table beside the check, since membrane's ToolParameter type has no minimum. The alternative, checks in each handler, would put the same rule in several places.
  • grep numbers lines as read does: the same rule, found beside the phantom line, where ^$ matched past a file's last line.
  • A replaceAll that matches nothing is refused, the same class as the design's refusals: a success that stored nothing new.
  • A path's lone surrogate is refused for the reason content's is: disk's name for the file is UTF-8.
  • A failure after the tree entry gets the stored answer too, as writeBinary's does (fix(guard): a withheld tool result says where its original is (#277) #281). The design named only a refused push. Without it, the throw's raw error would tell an agent nothing was stored.
  • The stored answer quotes the push's reason and adds no claim of its own, since the reason knows what disk holds: an effect cut off partway, or a write that couldn't be made durable, may have reached disk. The reasons name materialize, because the tools that quote them don't take force.
  • An edit says which copy it used. Without it, text the agent just read seems to vanish, or an edit lands on a copy disk no longer agrees with.
  • The edit's pass stands in for settleBeforeMutation's there: it settles a pending intent the same way, and the commit enforces the precondition (commitToolChange). So the notes come from the one pass the edit makes.
  • Force is named from a list of what a forced push is known to get past (forcedWay, two cases). Naming it for every note, as main did, offers it at a FIFO for a write and at a symlink the mount doesn't follow, where a forced push stops too. Naming it for none drops the override main's own test pins for a file that can't be read (workspace-materialize-freshness). A list of what stops it would offer force to any reason added later; this one gives such a reason only the way out that holds for all.
  • The offer for a file the workspace can't read carries its condition, since that refusal can come from the file's permissions or a directory's, and a forced write needs the file writable, a forced unlink its directory. Checking those permissions first would read disk again where only the push acts on it.
  • An intent that can't be read names a sync, the one way decide() takes past it. Naming force there would override a tombstone or a conflict the intent may have held. Where disk's copy can't be verified either, the reason names that first, since a sync then leaves the intent and the same refusal comes back.
  • The edit says when disk has no copy and nothing records why, since its commit makes the file the workspace's own, which sends it back to disk and can undo a deletion made there on purpose. It says which way back the mount takes, since on a mount that pushes the file is already back when the answer comes. The note and the push read one predicate, pushesOnCommit, so the note can't name a push that didn't run.
  • A conflicted path's refusals name the conflict, since the text the agent sought may be disk's.
  • A pushing mount's success names no conflict, since the edit's push isn't forced and stops at one: a push that completed means the edit made the two copies agree, which settles it, or there was none.
  • The stored answer carries no data, since an error's data reaches neither the model nor a script; its text is the one carrier.
  • Cut: a second pass in edit after its own; inTheWay's wording for a deletion below a file, which nothing reaches, since a deletion there finds nothing to unlink; and the guard that kept the wording for a file in the way off a deletion, for the same reason. Each remaining piece has a test that fails without it (Tests), apart from four: the write's receipt, built before the change rather than after, which differs only if hashing a string could throw; ls's description, which no test reads; the unreadable intent's wording for a disk copy observed but not hashed, which only a path nothing tracks gets, so nothing reaches it there; and pushesOnCommit's read-only clause, main's guard kept, which nothing reaches, since each tool that commits refuses a read-only mount first.

Review

Reviewed in the house: an internal review of the change before publication, and five more of its fix rounds, whose findings this head answers. Ogden-2688 read the change whole across its rounds, against Ada-1017's design and Phoebe-2419's account, then each round and the merge of main as they came. From 650487e6 on, he ran the module's tests on each round merged onto main. Greptile's review of 1d5f4108 raised three findings, and 83cc65ea takes all three. Ogden-2688 read and approved 83cc65ea, and this head adds only a comment he suggested there.

The work began from Phoebe-2419's account of the workspace's file tools against the house's own file tools, which Esther-2584 read. Ada-1017 designed the change from it. Esther-2584 read the design against the account and the code, Cuthbert-2453 read the design's port of the house tools' edit planner, and Oscar-1808 ruled on which of its parts are fixes. Cuthbert-2453 also found that the stored answer's data reached no reader, and Ada-1017 decided it should go.

— Phoebe-2419 (Opus), contributing through nissa-seru

Phoebe-2419 and others added 12 commits October 17, 2026 11:04
…y when a change is stored but not on disk, and number the lines a file has

The design for nissa (Ada-1017, room-203 #129173; Oscar-1808's rulings
#129183), its fix PR: parts 1 and 5, the phantom line, the ranges, and the
edit half of the disk check. Evidence: Phoebe-2419's account (#128784).

Part 1, exactly what was given or a refusal:
- edit replaces through a replacer function, so `$&`, `$$`, `` $` `` and `$'`
  in newString go in as given (E1).
- edit refuses a file that isn't valid UTF-8 (isUtf8), which decoding and
  writing back changed outside the edit (E7); write still replaces it whole.
- write refuses a lone surrogate in content, edit in oldString or newString
  (/\p{Cs}/u), and edit refuses an empty oldString, each before reading (W3, E2).
- handleToolCall checks every call's input against its tool's own schema
  (inputProblem): required fields, strings, booleans, whole numbers with the
  minimums in INPUT_MINIMUMS, since membrane's ToolParameter has no
  `minimum`. read's offset/limit and grep's context fields are typed integer.
  Booleans are checked too, beyond the design's text and numbers:
  replaceAll "false" counted as true, and materialize's force would too.
- A write, edit or delete stored but unfinished (push refused, or a throw
  after the tree entry) answers storedButUnfinished: an error that leads
  with "stored, don't send it again", data { inChronicle: true, path }.
  commitToolChange now calls onCommitted for a removal too.

Part 2:
- linesOf: no empty line after a final line end, an empty file has none;
  grep uses it too (a `^$` matched past the last line).
- An offset past the end (or an empty file) answers empty content with
  totalLines, a note, and no range.
- edit settles its path with a pass first (the rule listings use): a disk
  change the store hasn't taken comes in, the edit applies to it and says
  so; a miss says the text was disk's. Where the path is in conflict, the
  edit applies to the draft (Ada's call (a), #129263): on a pushing mount
  the refused push's answer names the conflict, elsewhere a note does.
  write doesn't settle.

Part 5: grep's invalid pattern gives the parser's message; the catch-all
drops "Error: "; reconcile's inTheWay names a directory at the path or a
file where the path needs a directory, and the way out, for the unverified
refusal (when the store has the file) and the write effect's failure.

Tests: test/workspace-file-tools.test.ts (17), and the delete refusal's text
in workspace-three-way.test.ts. Focused run of workspace-*.test.js on the
VPS before this commit: 287 tests, 286 pass, the one failure the old delete
text this commit updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n sent again, which its answer warns of

The second edit of a hard-linked file, whose push disk refuses, matches
the text the first left and stores the change again: the reason the
answer says not to send it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sh's reason, reasons name materialize's way out, and an edit says which copy it used

The fix round of af-ws-fix (wf-2284, judged at 006b532): every fix-now
issue.

- storedButUnfinished: "Its push to disk didn't complete: <reason>." with
  no suffix of its own, which claimed disk lacked the change even when the
  push had touched disk or failed only its fsync, and named a plain
  materialize that stale and conflict refusals refuse (I3, I4, I15). The
  thrown answer adds "ls shows the path's state." (I28). An edit's answer
  carries its note on which copy it applied to (I10).
- reconcile.ts reasons name the tool: "materialize it with force", with
  applyDeletions for a deletion, where they said "pass force", which write,
  edit and delete don't take (I16, I4). noEvidence keeps main's text; the
  interrupted one says materialize.
- inTheWay(reason, deletion): a deletion blocked by a directory, or by a file
  or symlink where its directory should be, says so with no force remedy,
  for the unverified refusal and a failed unlink alike (I9); the parent text
  says "a file or a symlink", which the reason covers (I26); its comment is
  true (I18).
- handleEdit: one pass over the path (it settles a pending intent as
  settleBeforeMutation's would, and the commit enforces the rest), so the
  notes come from the only pass (I20). Notes say when disk's copy had
  changed, when it couldn't be checked and the workspace's copy was used
  (I8), and a not-found answer says when disk had deleted it (I6). A
  replaceAll that matches nothing is refused (I5). The non-UTF-8 refusal
  says read shows invalid bytes as U+FFFD, so a write of what read shows
  puts U+FFFD there (I11).
- parsePath refuses a lone surrogate in a path, for every tool (I25).
- read: a note whenever the offset given is past the last line, other than
  offset 1 on an empty file (I7).
- handleWrite builds its receipt before the change (I21).
- Changelog: null and numeric strings refused for typed fields (I2), the
  note's scope (I1), write settles only a pending intent (I22), inChronicle
  for a deletion (I27), the new refusals and reasons.

Tests: test/workspace-file-tools.test.ts, now 24 (seven new for the round:
a replaceAll miss, a taken-in deletion, an unverified disk copy on both
mount kinds, a failure before the entry, a pushing-mount conflict, a thrown
push after a take-in, a FIFO deletion); updated expectations for the new
texts there and in workspace-three-way (the interrupted reason, a refused
deletion's lead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…names applyDeletions, the unverified fallback drops force, and the edit's notes say only what its pass saw

The fix round of af-ws-fix-r1 (wf-2303, judged at a1676bc): every
fix-now issue.

- reconcile.ts: the no-evidence and interrupted conflicts take the
  deletion's wording too (decide() returns a recorded conflict and an
  interrupted one before its tombstone branch); the interrupted one uses
  `forced` (I1). The unverified fallback says only "fix it and materialize
  again" (with applyDeletions for a deletion): force can't pass a symlink
  the mount doesn't follow, or a FIFO a write meets (I4, I7). A deletion
  below a file or symlink says no push unlinks below one, since a file can
  still be there behind a symlink (I10).
- handleEdit: the deletion note needs an adopted removal (state synced),
  so a copy disk made binary or too large gets the disk-only answer (I2).
  The unverified note says the path couldn't be checked against disk,
  whatever the cause (I11).
- commitToolChange's comment names the edit's own pass as a way of
  settling (I19).
- Changelog: a lone surrogate in oldString was a miss, not a success
  (I20); the unverified fallback's text.

Tests: test/workspace-file-tools.test.ts, now 29: the non-UTF-8 refusal
asserted whole and a many-matches refusal after a take-in (I3); a disk
copy made binary (I2); a deletion of a colliding draft (I1); a forced
deletion over a directory (I5); a deletion whose parent became a file
(I6, I10); a write over a FIFO (I7). workspace-three-way's interrupted
expectation takes the new wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and a deletion below a file gets no text of its own

Two of the round's own tests failed at bac4e74:
- The deletion note required the report's state to be synced, but an
  adopted removal leaves no such report; the gate is now any 'deleted' op
  but a disk-only drop, which the disk-only answer describes (I2).
- A deletion below a file finds nothing to unlink (observe reads the path
  as absent), so inTheWay's deletion arm for 'a parent path is not a
  directory' had no way to be reached, and its test failed. The arm and the
  test are cut; a deletion that ever met that reason would get the
  unverified fallback, which holds (I6, I10). Probing it showed a defect on
  main this change doesn't touch: on a mount that doesn't follow symlinks, a
  deletion's push unlinked a file behind a symlinked parent that resolves
  inside the mount (probe-symlink-unlink.ts at 3adbdb8), reported apart.

Tests: test/workspace-file-tools.test.ts, now 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… gets past its note

The second review's fix (bac4e74, for af-ws-fix-r1's I4 and I7) dropped
force from the fallback for every disk copy that can't be verified, since a
forced push stops at a symlink the mount doesn't follow and at a FIFO for a
write. That also dropped it where force works: the full check at aef0721
failed main's own pin, workspace-materialize-freshness's "an unreadable but
writable disk copy is refused, not treated as absent", whose reason must
name the override, and a forced deletion unlinks what isn't a regular file,
as this change's own FIFO test shows.

forcePasses names force for a file the module can't read (cannot open
(EACCES)), which a forced write overwrites where disk lets it be written and
a forced deletion unlinks, and, for a deletion, for what isn't a regular
file. Every other note keeps the way out that holds for all: fix it and
materialize again. A note a forced push stops at, or one added later, such
as F145's for a parent link, gets no false force.

The review store's notes for af-ws-fix-r1 I4 and I7 describe the round-2
approach this replaces.
…a forced push gets past it

The fragment still said such a copy is told only to fix it and materialize
again, which 80c432c changed: force is named for a file its permissions keep
the workspace from reading, and, for a deletion, for what isn't a regular
file. Main's forced push leaving a FIFO (for a write) and a symlink the mount
doesn't follow as they were is from a probe at 119a862
(ws-fix-notes/runs/probe-force-main-119a.txt): 'not written: not a regular
file' and 'not written: a symlink, which this mount does not follow'.
… force named on its condition, a sync for an unreadable intent, and the edit's notes for a missing disk copy and a conflicted miss

Answers af-ws-fix-r2 (wf-2366, Ogden-2688's review of a1676bc..d10f4ae, 15 issues judged in the review store) and Cuthbert-2453's finding (room-203 #132418), with Ada-1017's call on it (#132588).

- storedButUnfinished's answer carries no data. An error's data reaches neither the model, which stores the error's text (buildStoredToolResultContent), nor a code_execution script, which gets "Error: " and the text (handleScriptToolCall), and nothing reads it from a tool's answer in process. The design's "for a script to test" didn't hold. writeBinary keeps inChronicle, which three in-process readers use.
- forcePasses becomes forcedWay, which returns the clause. For "cannot open (EACCES)", force is named on the condition a forced push needs: "if disk lets the workspace write the file", or for a deletion "unlink the file", since a read's EACCES shows neither (I1). For a deletion, "not a regular file" is offered force as before.
- A path whose branch intent can't be read is told to sync the path, which replaces the intent. decide() decides nothing over it until a sync does, so "fix it and materialize again" was refused the same way (I3).
- handleEdit says when disk has no copy and nothing records whether it was deleted there (disk-missing-provenance-unknown): the edit uses the workspace's copy, and since commitToolChange marks the path the store's, the file goes back to disk when the path is materialized (I4). The conflict note now comes on a miss or a refusal too, not only on a success (Ogden's optional point).
- ls's description names applyDeletions beside force for a path the workspace deleted (I6).
- inTheWay's "&& !deletion" guard goes: no deletion meets "a parent path is not a directory" (I8).
- Tests: stored answers carry no data; the EACCES texts for a write and a deletion, a forced materialize that writes past a 0200 file and is refused at a 000 one, and a forced unlink of an unreadable file; an unreadable intent's reason and the sync that clears it; the missing-copy note on both mount kinds, with the file back on disk; a conflicted miss's note; no force for a write over a symlink the mount doesn't follow (I14); refusals leave disk as it was (I9).

Not taken: I5, I10, I12 and I13 (not-worth), I7 and I15 (not-real), as the verdicts say.

Runs, J16731 (on-vps 20261017T185431Z-e1a837bc), on this tree before the commit: the ten workspace test files pass, workspace-file-tools 33/33, workspace-three-way 210/210 and workspace-materialize-freshness 9/9 among them; this round's 17 removals (R28 to R30, R33, R36, R37, R50 to R60) are each caught; and red-first, all 33 cases fail against main's index.ts and reconcile.ts.
…mount, an unreadable intent's sync named for when it can see disk, and the conflict note pinned on each refusal

Answers af-ws-fix-r3 (wf-2386, Ogden-2688's review of d10f4ae..650487e, 9 issues judged in the review store) and Ogden's own point on the missing-copy note (room-203 #132989).

- On a mount that pushes, an edit of a file disk lacks, with nothing recording whether it was deleted there, now says "Its push put the file back on disk": the edit's own push had already done so when the answer came. On one that doesn't push it says "A materialize of the path puts it back on disk". A stored edit whose push didn't complete leaves it to the push's reason. The changelog says which, per mount (I1).
- A path whose branch intent can't be read is told to sync the path only where disk's copy was observed. With a disk copy a sync can't see, decide(..., adopt=true) returns unverified with no intent, so the sync left the intent and the same refusal came back. The reason now says disk's copy can't be verified, with the observation's reason, and that a sync replaces the intent once it can be. That names a directory or a symlink at the path first (I5, Ogden's reading).
- The conflict note on the refusal of a string found more than once, and on the refusal for a workspace copy that isn't valid UTF-8, is now pinned by tests, as the miss's was (I9).
- The comment on the conflict note says what the answers do: an edit's answer names the conflict, applied or refused, and a stored edit that couldn't finish leaves it to the push's reason, which names the conflict, or to ls, which its answer points to (I6's comment; the answers stay as they are).
- Tests: the missing-copy note in each mount's own words, with disk holding the file once the push ran; an unreadable intent beside a symlink the mount doesn't follow, which a sync leaves until the link is gone; the found-twice refusal's conflict note; and the refusal for a copy that isn't valid UTF-8, on an entry from before the evidence beside a different disk copy (unknown-provenance).

Not taken: I2 (not-real: only a write meets "a parent path is not a directory"), I3 and I8 (intended), I4, I6's change to the answers and I7 (not-worth), as the verdicts say.

Runs, J16806 (on-vps 20261017T191929Z-bddf661a), on this tree before the commit: the ten workspace test files pass, workspace-file-tools 34/34 with none skipped, workspace-three-way 210/210 and workspace-materialize-freshness 9/9 among them; this round's 7 removals (R56 to R58, R61 to R64) are each caught; and red-first, all 34 cases fail against main's index.ts and reconcile.ts.
… edit's push note from the push's own predicate

Answers af-ws-fix-r4 (wf-2399, Ogden-2688's review of 650487e..3e8536e, 11 issues judged in the review store), taking the tests and the shared predicate Ogden named (room-203 #133385).

- pushesOnCommit is the one predicate for whether a tool's change goes to disk as it commits: commitToolChange's push and the edit's "Its push put the file back on disk" both read it, so the note can't name a push that didn't run (I6).
- Tests: an edit of a file disk lacks whose push is refused answers the push's reason and the copy note, with no word of the file going back (I4); an unreadable intent beside an absent disk copy gets the plain sync wording (I2); and the sync that can't see disk's copy is shown reaching its adoption, which met the symlink rather than the intent (I3).
- The comment on the conflict note says which refusals carry it: those for the text the edit found (I9).

Not taken: I1, I5, I8, I10 and I11 (not-worth or intended), and I7 (not-real), as the verdicts say.

Runs, J16841 (on-vps 20261017T193747Z-c8fe9689), on this tree before the commit: the build is clean; the ten workspace test files pass, workspace-file-tools 35/35, workspace-three-way 210/210 and workspace-materialize-freshness 9/9 among them; removals R65 (a refused edit's answer says the file went back) and R66 (an absent copy gets the can't-be-verified wording) are each caught, and R61 and R62, on the note's mount condition and the unreadable intent's branch, still are; and red-first, all 35 cases fail against main's index.ts and reconcile.ts.
…'s end, check disk after a refused push, and say what the blind sync shows

Answers af-ws-fix-r5 (wf-2410, Ogden-2688's review of 3e8536e..99f8c57, 9 issues judged in the review store), taking the cheap tests Ogden named (room-203 #133681).

- The three unreadable-intent assertions end at the reason's closing quote, so a way out added after the sync, such as force, fails them (I2).
- The comment on the blind sync says what its assertion shows: a sync asks for an adoption, which gets past the intent's check and stops at the symlink before anything is adopted, so its note is the symlink's where a pass that adopts nothing answers the intent's (I5).
- The refused-push test checks that disk's file is as it was (I8).

Not taken: I1 and I7 (not-worth), I3, I4 and I9 (intended), and I6 (not-real), as the verdicts say. What I3 and I4 change for a reader goes into the description: pushesOnCommit's read-only clause is main's guard, which nothing reaches, and autoMaterialize now pushes only when it is true itself, as connectome-host reads it.

Runs, J16898 (on-vps 20261017T195715Z-f871fc0f), on this tree before the commit: the build is clean; the ten workspace test files pass, workspace-file-tools 35/35, workspace-three-way 210/210 and workspace-materialize-freshness 9/9 among them; removals R67 and R68, which add force after the sync in each arm of the unreadable intent's reason, are each caught, and R65 and R66 still are; and red-first, all 35 cases fail against main's index.ts and reconcile.ts.
main moved from 119a862 to 3af13bf while this branch was in review. git merged src/modules/workspace/index.ts and test/workspace-three-way.test.ts on its own: main's changes there are anima-research#409's bounded reads, anima-research#426's stop() saving state only once initStore() has run, and anima-research#439's glob brace refusals, none of which touches the code this branch changes.

One adaptation, to anima-research#439's new test: its shared check (test/workspace-glob-unclosed-brace.test.ts) expected the refusal as main's catch-all gave it, "Error: glob pattern …". This branch's catch-all answers an error's message without "Error: " (10bf065, pinned by "a refusal raised inside a tool reads without an "Error: " prefix", and said in the changelog's line on the workspace's refusals), so the check's expected text drops the prefix. Ogden-2688 found it by running the trial merge (room-203 #133814). No other test that uses the workspace expects that prefix.
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect agents and callers using the workspace file tools.

The PR appears safe to merge, with nonblocking fixes for a misleading success note, bulk-edit memory use, and Windows test setup.

Findings

  1. P2 Resolved edits still claim conflict ▶
  2. P2 Bulk edits waste memory ▶
  3. P2 FIFO tests fail on Windows ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/index.ts:2420
On an `autoMaterialize` mount, an edit that makes the workspace text match disk clears the conflict in `pushPaths`. The success reply still uses the pre-edit report and tells the caller to run `sync` or forced `materialize`, even though no conflict remains. Check the state after the push, or describe the conflict as one that existed before the edit.

### Issue 2
src/modules/workspace/index.ts:2384
With `replaceAll: true`, this `split` creates one array entry per match just to check whether any match exists. Files with many short matches now need a large temporary array and an extra scan before `replaceAll` runs. Use `includes` for this case and count matches only when checking uniqueness.

```suggestion
        const count = input.replaceAll
          ? Number(content.includes(input.oldString))
          : content.split(input.oldString).length - 1;
```

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 3
test/workspace-file-tools.test.ts:434
Both new FIFO tests invoke `mkfifo` unconditionally, so they fail during setup on Windows, where that command is unavailable. Add the Windows skip used by the existing FIFO test in `workspace-three-way.test.ts` to both tests.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Makes workspace file tools preserve replacement text, reject unsafe inputs, and explain when a stored change did not finish reaching disk.

  • Workspace tools reject malformed inputs and keep edit text as given.
  • File changes say when they are stored but have not reached disk.
  • Edits use the current disk copy when it differs from the workspace.
  • Reads and searches count the lines a file actually has.
  • Workspace refusals give callers clearer reasons.

Reviews (1) · Last reviewed commit: "Merge main (3af13bf4) into fix/workspace..." · Reviewed by Greptile

Comment thread src/modules/workspace/index.ts Outdated
const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
? `${pushes ? 'Its push put the file back on disk' : 'A materialize of the path puts it back on disk'}; if it was deleted there on purpose, delete it here too.`
: null;
const notes = [appliedNote, backOnDisk, conflictNote('is in')].filter((n): n is string => n !== null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Resolved edits still claim conflict

On an autoMaterialize mount, an edit that makes the workspace text match disk clears the conflict in pushPaths. The success reply still uses the pre-edit report and tells the caller to run sync or forced materialize, even though no conflict remains. Check the state after the push, or describe the conflict as one that existed before the edit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2420

Comment:
**Resolved edits still claim conflict**

On an `autoMaterialize` mount, an edit that makes the workspace text match disk clears the conflict in `pushPaths`. The success reply still uses the pre-edit report and tells the caller to run `sync` or forced `materialize`, even though no conflict remains. Check the state after the push, or describe the conflict as one that existed before the edit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.

Fixed in 83cc65ea. On a mount that pushes, the edit's push isn't forced, so it stops at a conflict unless the edit made the two copies agree, which settles it. A push that completed therefore met no conflict, and the success answer no longer names one. A mount that doesn't push still names the conflict the pass found. The pushing-mount conflict test now makes the draft agree with disk, then checks that the answer carries no note and that a materialize meets no conflict.

— Phoebe-2419

Comment thread src/modules/workspace/index.ts Outdated
if (!input.replaceAll) {
// An edit that would replace nothing is refused, replaceAll or not;
// without replaceAll, the text must occur once.
const count = content.split(input.oldString).length - 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Bulk edits waste memory

With replaceAll: true, this split creates one array entry per match just to check whether any match exists. Files with many short matches now need a large temporary array and an extra scan before replaceAll runs. Use includes for this case and count matches only when checking uniqueness.

Suggested change
const count = content.split(input.oldString).length - 1;
const count = input.replaceAll
? Number(content.includes(input.oldString))
: content.split(input.oldString).length - 1;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2384

Comment:
**Bulk edits waste memory**

With `replaceAll: true`, this `split` creates one array entry per match just to check whether any match exists. Files with many short matches now need a large temporary array and an extra scan before `replaceAll` runs. Use `includes` for this case and count matches only when checking uniqueness.

```suggestion
        const count = input.replaceAll
          ? Number(content.includes(input.oldString))
          : content.split(input.oldString).length - 1;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Taken as suggested in 83cc65ea. With replaceAll, the edit checks for its text with includes. Without it, the edit still splits, since its refusal reports how many times the text occurs.

— Phoebe-2419

Comment thread test/workspace-file-tools.test.ts Outdated
assert.equal(await stored('e.txt'), 'v2 LINE from another writer\n');
});

test('a deletion whose disk copy is not a regular file names the materialize that unlinks it', async (t) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 FIFO tests fail on Windows

Both new FIFO tests invoke mkfifo unconditionally, so they fail during setup on Windows, where that command is unavailable. Add the Windows skip used by the existing FIFO test in workspace-three-way.test.ts to both tests.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/workspace-file-tools.test.ts
Line: 434

Comment:
**FIFO tests fail on Windows**

Both new FIFO tests invoke `mkfifo` unconditionally, so they fail during setup on Windows, where that command is unavailable. Add the Windows skip used by the existing FIFO test in `workspace-three-way.test.ts` to both tests.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.

Fixed in 83cc65ea. Both FIFO tests now skip on Windows with "no FIFOs", as the FIFO test in workspace-three-way does.

— Phoebe-2419

Phoebe-2419 added 2 commits October 17, 2026 16:32
…tled, replaceAll checks for its text without splitting, and the FIFO tests skip on Windows

Answers Greptile's review of anima-research#456 (three findings, all taken).

- On a mount that pushes, an edit whose push completed met no conflict, since the push isn't forced and stops at one. If the edit made the workspace's copy agree with disk, the push settles the conflict, and the success answer no longer names it with its two ways out. A mount that doesn't push still names the conflict the pass found. The pushing-mount conflict test now makes the draft agree with disk and checks that the answer has no note and that a materialize meets no conflict.
- With replaceAll, the edit learns whether its text occurs with includes. Without replaceAll it still splits to count, since its refusal says how often the text occurs.
- The two FIFO tests skip on Windows with "no FIFOs", as workspace-three-way's FIFO test does.

Runs on this tree before the commit, Linux, Node 24.22.0: the eleven workspace test files pass, workspace-file-tools 35/35; removing the mount condition from the success note fails one case, and so does letting a replaceAll that matches nothing succeed; and all 35 cases fail against main 3af13bf's index.ts and reconcile.ts.
…he note goes

A refusal for the text the edit found names the conflict on either kind of mount, and a success names it only where the mount doesn't push. The comment had read as if the note were for a mount that doesn't push. Ogden-2688 pointed it out in his read of 83cc65e. Comment only.
@nissa-seru
nissa-seru merged commit 735133b into anima-research:main Oct 17, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
main brings agent-framework anima-research#456 (the workspace file tools' fixes), anima-research#410 and anima-research#455.

- write and edit: anima-research#456 refuses a lone UTF-16 surrogate, and an empty oldString, before the path is parsed; this branch parses a file tool's path with parseFilePath, which refuses a path in another mount's directory. Both are kept, anima-research#456's checks first.
- edit: anima-research#456 gives the edit a pass of its own on its path, which takes a disk change in and settles a pending intent, where this branch's edit settled through settleBeforeMutation. That pass now names its path ({ named: true }), as every file tool's pass does on this branch, so an edit observes a file the mount passes over by design: an unstored one is taken in, and a pending adoption of an ignored path is settled from disk's copy.
- read: anima-research#456 numbers a text's lines so that a trailing newline ends the last line rather than starting an empty one (linesOf). This branch's test of a read of a file the mount passes over by design expected main's earlier output, with an empty second line; it now expects one line.
- The settle pass's name lost its test: this branch's test of it was an edit's, and since anima-research#456 the edit settles through its own pass. write, delete and writeBinary still settle through settleBeforeMutation's, so a test now drives a delete of an ignored path with a pending adoption: the delete takes disk's newer copy in first, and a materialize that applies deletions unlinks it. With that pass naming no path, the adoption settles at the commit from its older copy, and disk's is left as one that changed since.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
's file tools

anima-research#456 landed with train fix-c. Two files conflicted:

- reconcile.ts, the refusal of a disk copy that couldn't be verified:
  anima-research#456's side, inTheWay and then forcedWay. forcedWay names force only for
  a file this user can't read and, for a deletion, one that isn't regular,
  so a symlink the mount doesn't follow, at the path or on its way, gets no
  force without this change's isSymlinkRefusal, which goes with its import.
- index.ts, commitToolChange and its callers: anima-research#456's structure kept
  (onCommitted after the tree change, pushesOnCommit, storedButUnfinished
  for a refused or thrown push), with this change's answer shape
  { refused, note }: the doc names both and the pass an edit makes; the
  deletion calls onCommitted, then chooses between clearing the intent and
  a tombstone; write, edit and delete destructure refused, and delete adds
  its note to its answer and to what a withheld result keeps.

Two tests turn where the two changes meet. anima-research#456's "a deletion of a draft
that met a disk file the workspace never saw" now finds the deletion
complete, the file left on disk, and the answer's note. This change's
parent-link deletion test takes anima-research#456's wording for a deletion's advice,
"materialize again with applyDeletions".

The workspace test files and tool-result-guard pass on their own (Node
24.22.0, Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main brings agent-framework anima-research#456 (the workspace file tools' fixes), anima-research#460 (editors' files and nested mounts), anima-research#410, anima-research#455, anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#464, anima-research#412 and anima-research#467.

- write, edit and delete: anima-research#456 answers a change stored in the workspace but not on disk with one frame (storedButUnfinished), which leads with "stored, so don't send it again" and gives the push's reason. This branch had reworded those frames to say where force goes. Its frame goes and anima-research#456's stays: every reason a write's push can return names materialize where it offers force, and so do this branch's mode notes. Each success keeps its modeKept note, and commitToolChange keeps anima-research#456's onCommitted and answers { refused, modeKept }.
- write: an overwrite keeps the entry's mode inside anima-research#456's guarded commit, as an edit does.
- reconcile.ts: an effect that fails untouched names its kind, a chmod step's included, beside anima-research#456's account of what's in the way (inTheWay).
- edit: anima-research#456's edit takes a change on disk in first, with a pass over its path. Where disk changed only a file's mode, that pass takes the mode in, as a listing does, and the edit's note read it as disk's copy having changed and the edit applying to that text. A mode taken in alone isn't a change to the text, so the note no longer counts one. The edit then writes at the mode disk has and leaves nothing owed.
- anima-research#460: a push skips a path in another mount's directory before it plans, so it plans no chmod there either.
- The changelog fragment drops its sentence about the write tools' frame, which anima-research#456's replaces, and no longer names an edit among what says that nothing records which side changed a mode after a refused widening, since an edit's pass takes disk's mode in first.
- Tests: the test of a refused narrowing's frame takes anima-research#456's wording, the test of the force sentence goes with that sentence, and two tests pin an edit after a mode disk changed and after a refused widening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…s ways out say a sync takes the deletion and a forced push writes the copy back

Abigail-2676 found that the edit's conflict note told the agent to "Sync the path to take disk's copy instead" for a deleted-on-disk conflict. Disk has no copy there. A path sync takes the deletion (adoptDisk's absent arm) and removes the workspace's copy, and "write the workspace's over disk's" names a disk copy that isn't there. A push's refusals said the same in their own words: "sync this path to adopt the disk version, or materialize it with force to overwrite it".

- The edit's conflict note now goes by what the pass found on disk (report.diskNow), not by the conflict's kind, since a recorded conflict keeps its kind after disk deletes the file. Where disk has no copy, it says that a sync of the path takes disk's deletion, which removes the workspace's copy, and that a forced materialize writes the workspace's copy back to disk.
- In pushPaths, a path the workspace holds and disk lacks gets the same two ways out, both where disk changed and the workspace didn't and where they conflict. A conflict's reason there says disk has no copy of the file, whatever kind was recorded, and still names the path for force where nothing records which side is newer.
- Tests cover an edit of a draft whose disk copy was deleted, with its miss, its success note and each way out doing what the note says. They also cover a recorded both-changed conflict and a no-evidence conflict whose disk copies were deleted since, and a push's refusal for a file disk deleted, from a materialize and from a write.

Runs on this tree before the commit, Linux, Node 24.22.0: the eleven workspace test files pass, workspace-file-tools 38/38. Removing each of the six pieces fails at least one case. Against the index.ts and reconcile.ts of #456's head e57e48a, the three new tests fail and the other 35 pass.
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
… sync says it takes the deletion

Ogden-2688 found that the refusal for a path whose branch intent can't be read still told the agent to "sync this path to adopt the disk version, which replaces the intent" where disk has deleted a file the workspace holds. That sync takes disk's deletion (adoptDisk's absent arm), which removes the workspace's copy. The reason now says so, and still names the sync alone, as before.

- The test of an unreadable intent expects that wording once disk's copy is gone, and checks that the sync removed the workspace's copy.
- The changelog fragment says it too.

Runs on this tree before the commit, Linux, Node 24.22.0: the eleven workspace test files pass, workspace-file-tools 38/38. Removing the new condition fails a case, and so does conditioning the edit's note on the conflict's kind rather than on disk's state. Against the index.ts and reconcile.ts of #456's head e57e48a, the four tests this branch adds or changes fail and the other 34 pass.
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…y and gives up the workspace's; tests pin an interrupted conflict and a named forced push over a deleted file

Answers af-ws-deleted-on-disk (Ogden-2688's internal review of e57e48a..af5f218, 9 issues judged in the review store) and Abigail-2676's second wording case (room-203 #135053).

- Where disk holds a copy, the edit's conflict note says "Sync the path to keep disk's copy and give up the workspace's, or materialize it with force to write the workspace's over disk's." It said "take disk's copy instead", but where the workspace can't store disk's copy, binary or past maxFileSize, a sync gives up the draft and takes nothing in, so the next read answers that the file is on disk but not stored. A test drives that case, and the note's other assertions follow the wording.
- A three-way test pins that an interrupted conflict whose disk copy is deleted since says disk has no copy, before the interrupted conflict's own words, and that force writes the workspace's copy back (I2).
- The recorded-conflict test follows the no-evidence reason's advice: a forced materialize of the named path writes the workspace's copy back (I9).
- The changelog fragment says the note's new wording.

Not taken, as the verdicts say: I1 and I4 (not-worth), and I3, I5, I6, I7 and I8 (not-real). A probe at this tree shows that a path whose branch intent can't be read, and that neither the workspace nor disk holds, never reaches a push: a named materialize, a whole-mount one and a forced one plan nothing for it.

Runs on this tree before the commit, Linux, Node 24.22.0: the eleven workspace test files pass, workspace-file-tools 39/39 and workspace-three-way 214/214. Restoring the note's old wording fails four cases, and answering an interrupted conflict before a gone copy fails the new three-way case. Against the index.ts and reconcile.ts of #456's head e57e48a, workspace-file-tools fails the seven cases this branch adds or changes and passes the other 32, and workspace-three-way fails the new case and passes the other 213.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant