Repository navigation
fix(workspace): the watcher and a sync agree on what a mount passes over, and an outer mount leaves a nested mount's directory alone - #460
Conversation
…ver, and an outer mount leaves a nested mount's directory alone A running watcher and a sync without a path disagreed both ways about what a mount leaves out. The watcher handed chokidar 4 the ignore list as strings, which it compares with whole absolute paths, so it took in every file the list covers; and chokidar's atomic option hid editors' swap and backup files from the watcher while a sync took them in. connectome-host's _config mount versioned those files and wrote them back after an undo or a branch switch (connectome-host anima-research#278). - One rule says what a mount passes over by design: its ignore list, and editors' swap and backup files matched against an entry's own name (`^\..+\.sw[px]$`, `~$`, `\.subl.*\.tmp$`). The walk passes them over and counts them under passedOver; the watcher gets a function that asks the same rule of the mount-relative path, with atomic off. - A walk that isn't named checks its own scope first, and a pass that isn't named observes nothing beneath a directory passed over by design. A sync that names a path still syncs it (anima-research#276). - A mount nested inside another is kept in a list of its own on the outer mount's view, apart from its ignore list, judged lexically (stored keys with doubled or trailing separators included). Every pass, walk, push and grep of the outer mount passes that directory over; its file operations refuse a path there (readFileFromDisk's code other_mount, resolveAbsolutePath null), naming the innermost mount that holds it and the path to use; a push skips it, a named materialize or grep says whose it is, and ls and glob list an outer copy with that note. Copies the outer mount stored there before are passed over and left out of its materialize and status's pending counts. - parsePath and generatedFileKey judge containment against the mount's resolved root, so a root configured with a trailing separator or a relative path contains its own paths. - The sync tool's description, ignore's doc and a changelog fragment say so. Tests: test/workspace-three-way.test.ts and test/workspace-watcher.test.ts.
[Medium impact] The changes affect file syncing and file access across workspace mounts. Fix explicit access to ignored files and Windows nested-mount checks before merging. FindingsFix with agent prompt### Issue 1
src/modules/workspace/reconcile.ts:681-690
The new `!opts.named` filter also blocks explicit file operations. With `ignore: ['node_modules']`, reading an unstored file such as `work/node_modules/pkg/index.d.ts` now fails even when it exists on disk. `ensureSynced` calls this pass without `named`, so the file becomes `unobserved`; `read`, `edit`, and `delete` then find no tree entry. Previously these calls loaded the requested file.
Pass `{ named: true }` for the file tools' lazy checks, without enabling `adopt`, while keeping watcher and search passes filtered.
### Issue 2
src/modules/workspace/observe.ts:869-871
`posix.normalize` does not treat backslashes as separators on Windows, but `parsePath`, `holderOf`, and disk access do. With a nested mount at `a/b`, a named sync of `work/a\b` passes this check and walks the nested directory through the outer mount. Direct file operations reject that same spelling through `holderOf`.
This leaves the nested-mount fix incomplete on Windows. Normalize platform separators before comparing with `others`, or reject those spellings before starting the pass.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR gives scans and watchers shared rules for ignored paths and editor files. It also separates nested mounts from outer mounts' reads, writes, pushes, and pending counts.
Reviews (1) · Last reviewed commit: "fix(workspace): the watcher and a sync a..." · Reviewed by Greptile |
| } else if (!opts.named) { | ||
| const slash = path.lastIndexOf('/'); | ||
| const above = slash < 0 ? undefined : passedOverAt(path.slice(0, slash), mount.view.ignore, mount.view.others); | ||
| const own = above === undefined && !walk ? passedOverAt(path, mount.view.ignore, mount.view.others) : undefined; | ||
| byDesign = above ?? own; | ||
| } else if (scope.kind === 'dir') { | ||
| byDesign = passedOverBetween(scope.dir, path, ignoreBeneath(scope.dir, mount.view.ignore), mount.view.others); | ||
| } | ||
| if (byDesign !== undefined) { | ||
| gathered.facts.set(path, { kind: 'unobserved', reason: byDesign }); |
There was a problem hiding this comment.
Ignored files cannot be opened
The new !opts.named filter also blocks explicit file operations. With ignore: ['node_modules'], reading an unstored file such as work/node_modules/pkg/index.d.ts now fails even when it exists on disk. ensureSynced calls this pass without named, so the file becomes unobserved; read, edit, and delete then find no tree entry. Previously these calls loaded the requested file.
Pass { named: true } for the file tools' lazy checks, without enabling adopt, while keeping watcher and search passes filtered.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/reconcile.ts
Line: 681-690
Comment:
**Ignored files cannot be opened**
The new `!opts.named` filter also blocks explicit file operations. With `ignore: ['node_modules']`, reading an unstored file such as `work/node_modules/pkg/index.d.ts` now fails even when it exists on disk. `ensureSynced` calls this pass without `named`, so the file becomes `unobserved`; `read`, `edit`, and `delete` then find no tree entry. Previously these calls loaded the requested file.
Pass `{ named: true }` for the file tools' lazy checks, without enabling `adopt`, while keeping watcher and search passes filtered.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 3bbf1aee: this was a regression in this PR.
We ran one probe file on this PR's previous head and on main's workspace sources, with ignore set to ["node_modules"], ["node_modules/**"] and ["*.log"]. On main, read, edit and delete of a file the store didn't hold, such as node_modules/pkg/index.d.ts or build.log, all worked. At 049d2175 each answered that the file "is not in the workspace, and disk could not be checked (ignored by the mount)", and nothing changed on disk.
A file tool now names the path it acts on, as a sync naming it does (#276). Both passes it makes on its own path take named: true: ensureSynced, which read, edit, delete and readBinary run for a path the workspace doesn't hold, and settleBeforeMutation, which write, edit, delete and writeBinary run to settle a pending change on their path. ls, glob and grep still take nothing in beneath a directory the mount passes over, and a path in a nested mount's directory is refused before either pass, as before.
New tests in test/workspace-three-way.test.ts:
read,editanddeleteof a file the store doesn't hold, beneath an ignored directory, covered by an ignore entry by its own name, and as an editor's backup file;- a file tool naming a path in a nested mount's directory beneath an ignored one is refused, and the lazy pass takes nothing in there;
- an
editof an ignored path whose adoption is pending works from disk's later change.
With the previous head's two passes, the three file-tool tests and the pending adoption's edit fail, and taking either pass's named out alone fails its own tests. On Linux with Node 24.22.0, at 3bbf1aee, workspace-three-way passes 242 of 242 and workspace-watcher 17 of 17, each file run alone and again in one process.
Written by Nell-1783.
— Lottie-2661
| export function inOtherMount(relativePath: string, others: readonly string[]): boolean { | ||
| const lexical = posix.normalize(relativePath).replace(/\/+$/, ''); | ||
| return others.some((dir) => lexical === dir || lexical.startsWith(dir + '/')); |
There was a problem hiding this comment.
Windows paths bypass mount separation
posix.normalize does not treat backslashes as separators on Windows, but parsePath, holderOf, and disk access do. With a nested mount at a/b, a named sync of work/a\b passes this check and walks the nested directory through the outer mount. Direct file operations reject that same spelling through holderOf.
This leaves the nested-mount fix incomplete on Windows. Normalize platform separators before comparing with others, or reject those spellings before starting the pass.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/observe.ts
Line: 869-871
Comment:
**Windows paths bypass mount separation**
`posix.normalize` does not treat backslashes as separators on Windows, but `parsePath`, `holderOf`, and disk access do. With a nested mount at `a/b`, a named sync of `work/a\b` passes this check and walks the nested directory through the outer mount. Direct file operations reject that same spelling through `holderOf`.
This leaves the nested-mount fix incomplete on Windows. Normalize platform separators before comparing with `others`, or reject those spellings before starting the pass.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
By our reading this holds on Windows, and this PR leaves it as it was.
inOtherMount normalizes with posix.normalize, while holderOf resolves with the platform's resolve, so on Windows a sync, ls, glob or grep naming work/a\b isn't read as work/a/b is. It isn't a regression, since main took a nested mount's files in under every spelling. This repository's CI runs on Ubuntu and macOS, and none of us has run the module on Windows. The description now limits its claim about separators to platforms where / is the separator, and names Windows' \ under "Not changed".
Written by Nell-1783.
— Lottie-2661
There was a problem hiding this comment.
That’s fair. Given the PR explicitly limits /-separator guarantees to platforms where / is the separator, documents Windows \ behavior under “Not changed,” and preserves main’s behavior for those spellings, this is not a regression introduced by the change. I’m withdrawing the finding; Windows separator parity can remain a separate change if it becomes a requirement.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
… a file the mount passes over by design The passes a file tool makes on its own path weren't named, so the rule that a pass naming no path observes nothing the mount passes over by design left such a path unobserved there. read, edit and delete of a file beneath an ignored directory, covered by an ignore entry by its own name, or an editor's backup file, which the store didn't hold, answered that the file "is not in the workspace, and disk could not be checked (ignored by the mount)", where main read, edited or deleted it. And an edit with a pending adoption on such a path worked from the tree's copy rather than taking disk's later change in first. - ensureSynced, the pass read, edit, delete and readBinary make for a path the workspace doesn't hold, and settleBeforeMutation, the pass write, edit, delete and writeBinary make to settle a pending change on their path, both name the path, as a sync naming it does (anima-research#276). Listings and searches still take nothing in there, and a path in another mount's directory is refused before either pass and passed over by any pass. - The comments on the named option and on gather, and the changelog fragment, say so. The fragment's sentence on a sync naming a nested mount's directory now names `/` as the separator it reads. Tests (test/workspace-three-way.test.ts): read, edit and delete of a file the store doesn't hold beneath an ignored directory, covered by its own name, and as an editor's backup file; a file tool refused in a nested mount's directory beneath an ignored one, with the lazy pass taking nothing in there; and an edit of an ignored path whose adoption is pending.
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.
main gained anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#460, anima-research#464, anima-research#412 and anima-research#467 since 86a3683. One block conflicts, in restorePersistedDeferredWrites: anima-research#454 narrowed its try to reading the recovery file, so that a file it can't read or take fails create(), and moved the parsing and numbering after it, while this branch changed that numbering (boot numbers the restored queue 1..N). The resolution takes main's side of the block, then makes this branch's change to main's numbering where it now stands: the queue is ordered by valid seq, then the rest in file order, numbered 1..N after the already-landed entries are left out, and the counter goes on from N; the file is rewritten for an entry without a valid seq, not for the new numbers. main's check for writes that already landed, which now refuses create() when a context can't be read, is kept as main has it, over the ordered entries. Everything else merged without a conflict. The only other change of main's within forty lines of one of this branch's is anima-research#457's to an ephemeral run's idle watchdog, which now rejects a run that stop() has begun to end as stopped; it sits above the run's finally, which this branch changes, and neither depends on the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Two conflicts, both where main moved code this change also edits: - src/mcpl/checkpoint-manager.ts: main (anima-research#454) builds a feature set's tree and reads its host-managed state through loadHostState before holding it. This change's registration-first order is kept by that structure: registerFeatureSet takes main's form, and loadHostState registers the state's slot through ensureStateRegistered instead of main's catch-all, so a failed registration throws naming the state and leaves no tree, at registration and before a connect alike. - src/modules/workspace/index.ts: the import lines. main's observe.js names (anima-research#460) with this change's ensureStateRegistered import. After the merge every registerState call in src goes through ensureStateRegistered (17 sites, as before: main added none). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t back, and refuse a path not spelled plainly Anarchid's nightly review of d92344b (R1) found these, and a run at that head confirmed each (Linux, Node 24.22.0, Chronicle 0.4.0): - R1-1: planFile built a file's whole new text, twice, before applyEdits checked it against maxFileSize. A replaceAll of 5,000,000 "a" with 200 characters each aborted Node with a heap OOM under a 512 MiB heap, where main refuses it ("Invalid string length"). The planner now knows the new text's length from its counts before building it, and since each UTF-16 code unit takes a byte or more in UTF-8, a file whose length passes maxBytes is refused as tooLarge, "at least" that many bytes; the exact check after the build stays for text whose UTF-8 runs longer. edit_many's form: twelve edits each asking for every one of 5,000,000 occurrences held all their starts before the clash check and aborted too. Edits whose asks, past the 20 a refusal shows, come to more occurrences than the text has characters can't all apply, so they keep only what a refusal shows, and when each finds what it asks for, they're refused as crowded: more occurrences between them than the file has characters, so some of their matches overlap. - R1-2: Chronicle 0.4.0 appends a record and then writes its auto-snapshot (store.rs update_state_with_builder, 485-600 at v0.4.0; anima-research/chronicle#39), so a set can throw with its record in. edit_many then answered that it stored none of its edits while the store held them, and edit, write and delete answered with the store's error alone. commitToolChanges now reads a set that threw back: where every path holds what its change sets, the record is in, onCommitted and the paths' intents follow as for any set, and the error goes on in place of the push, so each tool says the change is stored. Where the entries can't be read back either, it throws CommitUnknownError, and edit_many says it can't tell and to read its files first. - R1-3: parsePath keeps a path as written, so work/sub/b.txt and work/sub//b.txt in one edit_many call were planned as two files from one text and set as two entries, and the second's push was refused. editablePath refuses a path with an empty, "." or ".." segment, naming its plain spelling. The other file tools keep such a path as written too, which anima-research#471 reports. - R1-5: an edit whose only change is its line ends is refused as having nothing to change, and the refusal now says an edit keeps the file's line ends and write replaces the whole file, line ends included. R1-4, pinning edit_many to anima-research#460's path rules, needs main merged in, and isn't in this commit. Tests: workspace-edit-plan has the size floor in process and the growing replaceAll in a child with an 80 MiB heap, and the crowded edits in process and in a child with a 48 MiB heap; workspace-edits has the "at least" refusal beside the exact one, a batch and a one-path set that record and then throw, a read-back that fails, the plain-path refusals for edit and edit_many, and the crowded refusal's wording; workspace-file-tools has write, edit and delete whose store records and then throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
, anima-research#462 and anima-research#466 Conflicts in src/modules/workspace/index.ts (11 hunks) and observe.ts (one), each resolved as the change on both sides: - The imports are unions, less readFile, which the merged code no longer calls (F145's readBinaryFromDisk reads through openContainedFile), and coveredByIgnore, whose use anima-research#460 replaced. - commitToolChange: anima-research#462 (F48) answers { refused, modeKept } and F145 { refused, note }. The merged answer carries all three, every return gives each, and the doc comment names both. - writeBinary, write and edit take anima-research#462's side, which reads modeKept; delete takes F145's, which reads note. anima-research#460 makes MountView's others required, so F145's test view gives others: [], as the anima-research#460 trial (1e3fbb6e9) did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to use there, and not the setting On a mount that doesn't follow symlinks, a link whose target lies in another mount's directory nested in this one (anima-research#460) was refused like any inside link, offering the host's followSymlinks: true. That setting isn't a way out there: on a following mount, the link would let this mount read and write in the nested mount's directory, which anima-research#460 refuses at the target's own path. Esther-2584 found it running the merged head. linkWords now takes the mount's nested mounts, by directory and name, and for a target in one of them names that mount and the path to use there, with no setting: '"work/l" is a symlink to "work/nested/sub", which this mount does not follow; it lies in mount "inner", nested in mount "work": use "inner/sub"'. MountView carries them as `nested`, filled from the module's nested mounts beside `others`, and the disk readers' refusal takes them too. A test from Esther's probe checks the reader's refusal and a write's, and that nothing is created in the nested mount's directory. Ada-1017 decided the words (room-203 #138359). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er_mount, and read_image keeps both link codes' refusals On Esther-2584's point (room-203 #139845) and Ada-1017's decision (#139889): - A symlink among a path's directories whose target lies in a nested mount's directory now refuses a disk read with `other_mount`, the code anima-research#460 gives that mount's own paths, rather than `parent_symlink`. Its message already names the mount and the path to use there. A host that maps `parent_symlink` to followSymlinks: true, which here would let this mount reach the nested mount's files, then can't offer it. linkWords and linkReadError ask the question through one helper, nestedHolder. - `parent_symlink` then means a link inside this mount, to a directory, a file, a FIFO or nowhere, whose words say what helps in each case. - read_image maps `other_mount` too. Without that case, or `parent_symlink`'s, the refusal comes back as not_found, and where the workspace's own copy failed to read as an image, read_image answers with that copy's failure instead. J18144 showed `parent_symlink`'s case untested: its removal passed every test. New tests pin both, through an inside and an outside link where the workspace's copy isn't an image, and in the nested-mount test. - The code's doc, linkReadError's, and openContainedFile's step 0, which still said `symlink` for a link on the way, name the three codes. `parent_symlink`'s message names the target only where the link leads somewhere. - The breaking fragment takes Esther's wording for operators: the path through the link's target, or a nested mount's own path; the setting only for a link to a directory; or moving the file. It names `other_mount` for callers, and the fixed fragment does too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path to use replaces The refusal for a symlink among a path's directories whose target lies in a nested mount's directory ended 'use "inner/sub"': the path to put in place of the link. anima-research#460's own refusal for a path in that directory uses 'use' for the file's own path, so an agent taking it that way would try the directory (Agnes-1009, room-203 #140076). It now ends 'use "inner/sub" in place of "work/l"', which keeps one message per link, as linkWords writes for every place it's used, the listing notes and checkDir's included. Ada-1017 chose the wording (#140093). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ories From the release with anima-research/agent-framework#468, a workspace mount that doesn't follow symlinks refuses a path through a symlinked directory, wherever the link leads, where earlier releases follow one that stays inside the mount. A link leading out keeps the code `escape`, one into a nested mount's directory gets `other_mount`, and any other gets a code of its own, `parent_symlink`, whose message names the link and, where it leads somewhere, its target, adding for a linked directory that the mount's host can set followSymlinks. - The web UI's /files/ route answers `parent_symlink` with 403 and the read's message, as it does a file that is itself a link. Its list of forbidden codes didn't hold it, so it would have answered 404. - An identity upload's refusal of one tells the agent that if the link leads to a directory, which the reason shows by saying the mount's host can set followSymlinks, the file can be named by its path there, and otherwise to ask the operator, giving them the reason. Its advice for `escape` now says the file lies outside this mount, since that check is per mount, and that one in another mount can be named by its path there. - The instructions module already gives any code but `symlink` and `escape` in the read's own words; a comment says what those words carry for `parent_symlink` and `other_mount`. - The recipe's doc for followSymlinks says it covers a link among a path's directories from that release. - The deployment doc's row for the route lists the two refusals, and says a mount doesn't follow links unless the recipe sets followSymlinks, where it said a recipe's mounts don't follow links. - The comments and docs that say what such a mount refuses (the instructions and activity modules', the README's, and an upload test's) add what the release with #468 refuses too. `other_mount` is the code anima-research/agent-framework#460, already on agent-framework's main, gives a path in another mount's directory nested in the one named; its message names the mount that holds it and the path to use there. The route answers it 403 too, where it would answer 404, and an upload's refusal of one gives the read's reason alone, as for a path no mount holds: the file is the nested mount's, so the outer mount's workspace isn't offered in its place. The unreleased fragment for the change that stopped such mounts reading a file that is itself a link said a recipe's mounts don't follow links because RecipeWorkspaceMount leaves followSymlinks out. A recipe has passed followSymlinks since mounts began passing every key through, so it now says a mount doesn't follow links unless the recipe sets it, and names setting it as a way out; and its sentence that a file reached through a linked directory is read as before now says it holds on agent-framework releases before #468. The tests that pinned a framework read's answer for a path through a linked directory now assert what the mount's own read gives, so they hold on agent-framework 0.22.2, which this host's lockfile installs, and on a release with #468: the route answers as the read does, the instructions module injects the file or alerts with the read's words, and the upload's refusal of a link leading out carries the read's own words, which differ between the two, with the outside advice. New cases pin `parent_symlink` and `other_mount` at each of the three, the nested-mount upload with a copy of the path in the outer workspace, a mount that follows links reading through a linked directory, and the route serving one; on #468 the route's real read carries the words the upload's advice keys on. An upload of a path through a linked directory that leads nowhere sends the workspace's copy where the read finds no file, as 0.22.2's does, and is refused where it gives parent_symlink, as #468's does. Neither #468 nor #460 is in a release yet. A release in this host's range (^0.22.2) that carries #460 brings `other_mount` to it, so a fragment says what the route and the upload do with it. This change carries no changelog lines for #468's effect on operators. Checked on Linux x64 (Bun 1.4.3, Node 24.22) at this tree: - tsc --noEmit is clean at the root and in web/. - The three changed test files pass 112 of 112, three runs. With main's three modules in place of this change's, 4 of the 112 fail. - Each of six one-line changes fails a test: parent_symlink or other_mount dropped from the route's list; the upload's parent_symlink advice dropped, or other_mount dropped from its early return; the instructions module giving its own words for parent_symlink or for other_mount. - bun test passes 2404 of 2404 across 180 files. - With agent-framework at #468's head (9453858a6) built from source and installed in 0.22.2's place: root tsc is clean; the three files pass 112 of 112, three runs, with the route's, the instructions module's and the upload's reads through a linked directory refused as parent_symlink; bun test passes 2404 of 2404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main at d9bd6dc brings anima-research#460, anima-research#462, anima-research#466, anima-research#424, anima-research#445 and anima-research#425 into code this change rewrites. The resolutions: - src/modules/workspace/index.ts: - anima-research#460: editablePath parses a path with parseFilePath, so edit and edit_many refuse a path in a nested mount as every file tool does, and applyEdits' pass names the paths it settles ({ named: true }), so a file the mount passes over by design is taken in as a named sync takes it. - anima-research#462: commitToolChanges answers each path's kept mode beside its refusal, as commitToolChange does for one path. edit's answer carries it as modeKept, and edit_many's in each file's entry. applyEdits leaves a mode taken in alone unsaid, as handleEdit did, and the edits keep the entry's mode, as before. - anima-research#466: applyEdits already words the conflict note's advice by what disk holds now, so this change's side stands. - anima-research#424: the imports keep both sides. - src/framework.ts, src/module-registry.ts and src/types/events.ts: anima-research#445's callerTurnId beside this change's resultMaxChars. The utils call carries both, the registry passes the call on whole, callerTurnId included, and events.ts documents both. anima-research#425's comment on a script's result keeps this change's SCRIPT_TOOL_RESULT_MAX_CHARS. - test/workspace-file-tools.test.ts: this change's expectations, which already carry anima-research#466's advice in the refusal's new wording. Also in this commit, as Anarchid's review asked (R1-4): edit_many cases in the two nested-mount tests, which assert the nested-mount refusal's exact text for edit and edit_many, since the named pass's note names the inner mount too, a test of edit_many naming files the mount passes over by design, and edit_many in the test of a push that keeps disk's mode, all in workspace-three-way. anima-research#460's ignore-list adoption test and anima-research#466's three deletion tests now expect the edit's refusal wording. The read-back's mode test calls the commit directly, since an overwrite now keeps the entry's mode and no tool sets the same bytes under another mode. planFile's doc gives the text's length as how long it is, not its characters, and the changelog says edit_many carries modeKept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The include (9f3e429) is on main 2482625. Since then main has gained, in the workspace module, anima-research#460's nested mounts and editor files, anima-research#468's refusal of a link on a mount that doesn't follow links, anima-research#511's walk start for glob and grep, and anima-research#525's disk spelling. Where they meet the include: - Nested mounts (anima-research#460): a path in a nested mount's directory is that mount's, whatever the outer mount's include covers. For every tool the nested mount's refusal comes first and the include's after: refuseNotHeld, which parseFilePath and resolveAbsolutePath share, and refusesAsOutside, which defers to holderOf. A nested mount's own include decides inside its directory. Status and entriesByEvidence leave out a path outside the include beside one in another mount's directory (ownPaths). - Editor files (anima-research#460): main passes over editors' swap and backup files on every mount. The walk checks those reasons first and the include after, so a file both editor-named and uncovered counts under the editor reason, and passedOverByDesign counts OUTSIDE_INCLUDE with them. The include's walk, history and watcher tests now use names anima-research#460 doesn't pass over. - Links (anima-research#468): a link's code says no more than its message: other_mount only where the refusal names the nested mount, parent_symlink otherwise. namesHolder (observe.ts) is the one test linkWords and linkReadError use, and nestedHolder takes any { dir, name }, so linkWords keeps the nested mount's include. - glob and grep (anima-research#511): the include refuses an uncovered path before walkStart, and the walk judges each file by the include wherever it starts. - Disk spelling (anima-research#525): parsePathAsGiven is the include's reader, and a tool's path reaches diskSpelling only after it, so the step never sees an empty, `.` or `..` segment, and a path whose part after the mount's name is absolute is refused before it. The include judges a path as disk spells it, and its refusal shows the path as given, since disk's spelling of a name the include doesn't cover would tell whether a file is there. Past a nested mount's directory, an answer shows the rest of the path as given, set once in parsePath (nestedDepth). resolveAbsolutePath stays synchronous and judges the path as given. diskSpelling keeps its branches for '', '.', '..' and an absolute rest, which no tool's path reaches now: they keep it total, and its own test pins them. Texts: MountConfig.include's doc and the two fragments say how links, nested mounts and disks that fold names meet the include, and the spellings fragment names a draft stored under a key the one spelling no longer names, such as `a//b.txt`, with how to recover it from the store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not only where a path names it The nested-mount checks (#460) read paths and roots lexically, so an outer mount reached an inner mount's directory three ways it couldn't see: - A root configured through a symlink, or beneath one, wasn't nested at all. The outer mount's sync took the inner mount's files in, and a read of `outer/a/b/x.txt` returned the inner's file. That held both for an inner mount configured through a link into the outer one, and for an outer mount configured through a link to the directory holding the inner one. - On a mount that follows symlinks, a link into the inner mount's directory passed every check. A read through it returned the inner's file and stored it as the outer's. A forced push through a directory link wrote a new file into the inner mount, and a forced unlink removed the inner's file. A forced push through a file link overwrote the inner's file. - On a mount that doesn't follow symlinks, #468 refuses every push, unlink and disk read through such a link. A write through it still stored a draft that no push could write. The changes: - Each mount's root is resolved once, at construction (realRoot, realpathSync.native, the same resolution as the async realpath the checks use). A root not made yet takes its nearest existing directory's real path, with the rest as configured. A mount is nested in another where its configured path or its real root lies beneath the other's, and each way it lies there is a spelling of its directory (#525's model), passed over by every walk and push and refused by the tools' lexical check. spellNestedDirs respells each spelling found at construction (`fromRoots`), not only the first. - MountView's nested mounts carry their real root. Every check that resolves a path canonically also asks nestedAtReal: locateDir, parentPlacement, observePath's followed link and openContained in observe.ts; checkOpened and unlinkContained's followed link in effects.ts; and openContainedFile in index.ts. A path whose real location is in a nested mount's directory is refused there, as `"work/l" leads into mount "inner", nested in mount "work": use "inner/sub" in its place`. createDir asks it before each mkdir, so no directory is made where a nested root, made or not, would be (Greptile's G3 on #282). - parseFilePath, on a mount with a mount nested in it, resolves the path (its nearest existing directory's real path, with the rest as given) and refuses one that lands in a nested mount's directory before anything is stored. On a mount that follows links the refusal is NestedMountError's, with "leads into" in place of "lies in". On one that doesn't, it's the link's own refusal (linkWords, as #468's readers and pushes give it), with its code. It runs before the mount's turn, since the checks where disk is acted on are the boundary (Ada-1017, room-203 #145666), and a mount with nothing nested skips it. - readFileFromDisk passes a WorkspaceReadError from parsing through with its own code, rather than reading every parse failure as `traversal`. - The watcher watches and reads its events against the resolved root: a root configured as `./m` matched none of chokidar's normalized paths, so the mount heard nothing. Tests: test/workspace-nested-mount-roots.test.ts, sixteen. They cover the three root configurations; Esther-2584's link into the nested directory, for reads, writes, the disk readers and a named sync; Ada-1017's link to a directory holding the nested mount; a file link into it, through a sync, a read and a forced push; #324's alias, through a forced push and the file tools; the disk reader after a link swapped in past the tools' check; a write through an unfollowed link; the four checks where disk is acted on, called directly (the contained write and unlink are #282's 577258f, by Ada-1017 at slimepriestess's ask); and a `./m` watcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d review round's fixes Ada-1017's call (room-203 #146358), after Oscar-1808's question (#146326): a synchronous realpath at construction held the whole event loop on a share that stops answering, the class #311 names. - The roots are placed at each start, awaited, before start sets anything (readNesting, where #525's spellNestedDirs read disk's spellings). The constructor nests by configured path, as #460 does, so a call before start has that nesting, as it has #525's spellings only from start. A root moved while the module was stopped is placed again at the next start. realRoot, the synchronous copy, goes: realBeneath places roots and paths. - realBeneath reads a link's target from the link's directory as it is on disk, and resolves nothing lexically on the way, so a `..` in a target is taken from where the links before it lead, as the kernel takes it (phoebe-af-mount-roots-r2 I1). A path it can't place, such as one behind a loop, is resolved lexically from there. - A mount whose real root is the outer mount's own, or holds it, keeps its nesting by configured path and has no real root to claim by (r2 I2): round 1's skip dropped #460's nesting for it too, so a following outer walked into the link and a write through it was stored. - read_image's mapping of other_mount keeps its comment true for the checks this change adds. - Tests: a link with a `..` target under a link (I1); the own-root mount on a following outer, through its link (I2, I4); a mount linked to a directory above the outer (I3); a write through a link to a place not made yet on a mount that doesn't follow links (I8); the dangling root link with a relative target (I10); a root moved while stopped; and, from Langston-2479's read (#146366), read_image with a link swapped in after the tools' check, and a path that is itself a link into a nested mount on a mount that doesn't follow links. The changelog says the last one's code changes from symlink to other_mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l's does Review finding R1-1 on anima-research#465: merged with main, read's own pass over its path lacked the { named: true } that anima-research#460 gave ensureSynced. So a read of a file the mount passes over by design (beneath an ignored directory, ignored by its own name, or an editor's backup) that the store doesn't hold answered that disk could not be checked ("ignored by the mount"), where main's read observes it. The three anima-research#276 tests in test/workspace-three-way.test.ts that read such a file failed. The pass now names the path, as a file tool names the one it acts on, so a read observes it as a sync naming it would, and those tests pass. Tests, in test/workspace-three-way.test.ts: a read of a held file beneath an ignored directory, and of one ignored by its own name, takes disk's change in first, says so and stores it, and then takes disk's deletion in first, as a read of any held file does. The anima-research#276 tests cover only files the store doesn't hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l's does Review finding R1-1 on anima-research#465: merged with main, read's own pass over its path lacked the { named: true } that anima-research#460 gave ensureSynced. So a read of a file the mount passes over by design (beneath an ignored directory, ignored by its own name, or an editor's backup) that the store doesn't hold answered that disk could not be checked, "ignored by the mount" or "an editor's swap or backup file", where main's read observes it. The three anima-research#276 tests in test/workspace-three-way.test.ts that read such a file failed. The pass now names the path, as a file tool names the one it acts on, so a read observes it as a sync naming it would, and those tests pass. Tests, in test/workspace-three-way.test.ts: a read of a held file beneath an ignored directory, and of one ignored by its own name, takes disk's change in first, says so and stores it, and then takes disk's deletion in first, as a read of any held file does. The anima-research#276 tests cover only files the store doesn't hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A running watcher and a sync without a path now agree about what a mount leaves out: the mount's
ignorelist, and editors' swap and backup files. They disagreed both ways. The watcher took in every file the ignore list covers, and a sync took in the editor files the watcher hid. connectome-host's_configmount, which versions the host's config files, was versioning the swap and backup files saved beside them and writing them back after an undo or a branch switch (anima-research/connectome-host#278).The problem
MountWatcherhanded chokidarconfig.ignoreas strings. By our reading of chokidar 4.0.3, it compares a string matcher with the whole absolute path (createPattern,index.js:25-29), so no pattern matched, and a watcher pass is apathspass, which has no walk, so nothing in it reads the list either (reconcile.ts:629-630at3af13bf4). On 0.22.1, withignore: ['*.log', 'cache/**']andwatch: 'always', a.logfile written and one edited while watched, and a file written undercache/, were all taken into the tree.main'swatcher.tsis unchanged since 0.22.1, and the red-first run shows its watcher still reporting what the list covers.atomicoption, on by default with fs.watch, ignores/\..*\.(sw[px])$|~$|\.subl.*\.tmp/, tested against the whole path (index.js:19,:639). The watcher never saw.gate.json.swporgate.json~written while it watched, while a sync took them in. And under a directory with a dot in its name, such as~/.local/…, it hid names ending in.swp:notes.swpwas reported under a root with no dot in its path, and under.local/work,main's watcher reported onlykept.txtofnotes.swp,report.v2.swp,notes.sublime.tmplandkept.txt.Changes
ignorelist, read as before;.name.swpand.name.swxas the whole name (vim), and names ending in~(backups) or in.subl…tmp(Sublime Text's saves in progress). These are the families chokidar'satomicoption hid, matched against an entry's own name by^\..+\.sw[px]$,~$and\.subl.*\.tmp$: vim's form from start to end, since a run of vim namesgate.json's swap file.gate.json.swp, and the others to the name's end. Soreport.v2.swpandnotes.sublime.tmplare someone's files, taken in asmain's walk takes them. A directory with such a name is passed over with what lies beneath it, asmain's watcher already did.passedOveras "an editor's swap or backup file", and a tracked copy is reported underincomplete, and underskippedas one disk couldn't be checked for, asmainreports a tracked copy the ignore list covers. It isn't decided.ignoreentry such asnode_modules, matches the directory but not what lies beneath it, so onmainanls,globorgrepnaming such a directory listed it and started tracking what it found. A path rule such ascache/**already matched every entry beneath, so its listing was empty, and it now names the region too. A sync that names the directory still syncs it (workspace: bare mount-wide sync reports totalSynced: 0 while per-path sync on the same file works — bulk and per-path disagree about reality in both directions #276).atomicoff, so the rule is the only filter. Withatomicoff, an editor's save by rename arrives as an unlink at once, and as an add onceawaitWriteFinishfinds the new copy settled. The pass reads disk, not the watcher's ops, so it takes the new copy in as a change when the copy is on disk by the time the batch is taken in, after the debounce's quiet spell. A copy still being written then is read as it stands, and taken in again whole when its add comes. A re-add after a quiet spell longer than the debounce is taken in as a deletion and then a creation. The final state is the same either way. By our reading of chokidar,main's atomic hold, withawaitWriteFinish, never read a copy mid-write after an unlink. Holding unlinks back would tie the pass to the ops, for a case that needs a write still running a debounce (300 ms by default) after its unlink.ignore, which is left as configured. Onmainthe constructor added the nested path to the outer mount'signoreas written: for a mount nested more than one level down (a/b) the entry matched nothing, so the outer mount took that mount's files as its own, and for one directly beneath (c) it matched the name at any depth, so the outer mount also passed over unrelated entries such assub/c. And since a sync that names a path setsignoreaside for what it names (workspace: bare mount-wide sync reports totalSynced: 0 while per-path sync on the same file works — bulk and per-path disagree about reality in both directions #276), a sync naming the nested directory took its files in. Now such a sync takes nothing in and says so underignored, and the region a listing names there names the mount that holds it and the path to use, with no advice to sync it, also where anignoreentry covers a directory above the nested mount. A mount whose name only begins with two dots, such as..cache, counts as nested, where, by our reading,main'sstartsWith('..')took it for one outside.materialize,materializeMount), forced or not, skips such a path; amaterializethat names one lists it underskipped, naming the mount that holds it and the path to use there, as a sync that names the path does underignored. The outer mount's file operations (read,read_image,write,edit,delete,readBinary,writeBinary,readBinaryFromDisk,readFileFromDisk) refuse such a path, naming the innermost mount that holds it and the path to use there;readFileFromDiskgives the codeother_mount, andresolveAbsolutePathreturns null. They judge the path lexically where it lands, so./a/b/x,a/c/../b/xanda//b/xare refused asa/b/xis, anda/b/../x, which lands beside it, isn't taken for the nested mount's; a link is the outer mount's own path (see Not changed). Where/is the platform's separator, every other check of a nested mount's directory, a pass's, a walk's, a push's andgrep's, reads repeated and trailing separators, and.and..within a path, the same way, stored keys included, except as Not changed says. A generated file the host registers at such a path is the same file whichever mount names it, and either reads it from its generator.grepreturns none of the outer mount's copy of such a path, andlsandgloblist it with a note naming the mount that holds it and the path to use. Onmaina write through the outer mount, or any copy its store held there, went to disk at the outer mount's next materialize, over the nested mount's file wherever disk still agreed with the outer mount's evidence, and a forced one, as an undo or a branch switch makes, whatever disk held. A read through the outer mount returned its own copy, stale once the nested mount had changed the file.main'sa/bmistake let the outer mount take a nested mount's files in, those copies stay in its branch's history and its listings, but a sync without a path counts their directory underpassedOver, notincomplete, so a sync that reports nothing incomplete has looked everywhere it should; and a barematerialize, one naming an ancestor, andstatus's pending changes, conflicts and workspace deletions leave them out (fileCountcounts the tree as it stands). No tool of the outer mount could clear them, so naming them as unreached never cleared. Nothing writes the store to drop them.parsePathandgeneratedFileKeyjudge containment against the mount's resolved root, so a mount configured with a trailing separator contains its own root, and one configured with a relative path contains its paths, and the path the refusal names works. Onmaina root configured asa/b/refused its own root as traversal, and a relative root refused every path.ignoredwith its own note.read,edit,deleteandreadBinarytake in a file the mount passes over by design that the workspace doesn't hold, asmaindoes. The pass thatwrite,edit,deleteandwriteBinarymake first, to settle a pending change on their path, observes it too. Only listings and searches (ls,glob,grep) take nothing in there. A path in another mount's directory is refused before either pass, and no pass observes it.ignore's doc name the files.changelog.d/workspace-watcher-agrees-with-scans.fixed.md.A copy already stored stays as it was to every pass that names no path: a later change on disk isn't taken in, and its deletion is taken in where a walk lists its directory, as for anything a complete listing lacks. A pass that reaches such a copy by its path alone, as a
grepnaming it does, passes over it. A copy beneath a directory passed over by design stays, deleted on disk or not, and whether the directory is still there or agrepnames the copy, since no pass that isn't named observes anything there. A materialize treats a stored copy as any file the workspace holds, as it does a path the ignore list covers: it writes the workspace's copy while disk has what they last agreed on, as after a branch switch to history that holds it, and leaves a change disk made alone, as a conflict, unless forced.What each part is for, and what was cut
The change exists so that a running watcher and a sync without a path agree about what a mount leaves out.
ignoredas a function asking the walk's rule of the mount-relative path. chokidar 4 compares strings with absolute paths, so without it the ignore list doesn't reach the watcher at all.atomic: false. chokidar's own filter tests the whole path, unanchored, so while it's on the two sides can't agree. What it costs is the partial-copy and slow-save cases described under the watcher above.main's watcher already passeddrafts~/over. It costs nothing beyond the rule.sub/cbecause of a mount atc, and a sync naming the nested directory from taking its files in. It costs one list on the mount's view, which the walk, the watcher and a named sync's note read, asked before the ignore list, so that an entry covering a directory above a nested mount can't hide it../a/b/xread the nested file from disk. One helper finds the mount that holds the path, which the refusal, a named sync's note, a named materialize's skip, and a listing's note and region each name with the path that works, the listing's from one place where a pass's result comes back. One lexical reading of the path, in the check every pass, walk, push andgrepasks, keeps them all on the spelling the refusal judges, except as Not changed says: a check of the path as typed let a sync ofa//btake the nested mount's file in undera//b/inner.txt, which chronicle accepts as a tree path.grepleaves out the outer mount's copy of such a path, since it would return that copy's content, whichreadrefuses.grep's check of a path it's given, before it searches. No pass observes a nested mount's directory, so a file named there makes a report only where the outer store holds a copy, andgrepleaves that report out. Without the check, agrepnaming a file in a nested mount's directory would answer nothing, with no word of whose the path is. It costs one lookup of the holder per path named.incompletenever cleared, a barematerializenamed them as skipped every time, andstatuscounted them as pending for good. It costs one check of each path against the list, in the selection, the evidence's entries andstatus, and one reason the walk's region doesn't count as unreached.parsePathandgeneratedFileKey. Without it, the path the refusal names fails when its mount is configured with a trailing separator, and a mount configured with a relative path refuses every path, generated files included. It costs oneresolveof the root.gatherleaving what's passed over by design unobserved. A pass that isn't named observes nothing beneath a directory the mount passes over by design, whether a walk or a path reached it, and no pass observes another mount's directory. A pass that names a directory sets aside only what covers its scope, as its walk does, so a directory passed over by design beneath the scope stays unobserved, present or gone. Without it, agrepnaming a file stored beneath such a directory took disk's copy in, and once the directory itself was gone, the listing of its parent proved the stored copy absent and it was taken as deleted. A path a rule covers by its own name is observed as a walk observes it, so its deletion is still taken where the walk lists its directory; only a pass that reaches it by its path alone passes over it. It costs one check for each path a pass observes.read,editanddeleteof such a file the workspace didn't hold answered that disk "could not be checked", wheremainread, edited or deleted it, and aneditwith a pending adoption on its path worked from the tree's copy rather than disk's change. It costs one argument at each of the two passes.mainneeded so as not to add the same pattern toignoretwice. A list of its own is only asked whether a path lies in one of its directories, so a repeated entry changes nothing..swoand Emacs's#name#, which both sides now take in alike. Which other names count as an editor's is a separate question. The mountincludelist, a separate change, is what lets_confighold only the files it names (The _config workspace mount versions editor swap/backup files and restores them at 0644 connectome-host#278).Tests
test/workspace-three-way.test.ts:notes.swp,report.v2.swp,backup.txt.bak,notes~draft.md,notes.sublime.tmpl,a.subl.tmp.csvand.cfg/notes.swp; a sync naming a path beneath that directory syncs it, with its note;passedOveras another mount's directory and takes none of their files, with ana/blisted by hand and a mount at..cacheamong them, and takes entries that only share a nested mount's name,cat.txtbeside a mount atcamong them; a sync naming a nested mount's directory, or a file beneath it, takes nothing in and says why underignored, naming the mount that holds it and the path to use, a directory's walk naming the region and a file making none; anls,globorgrepof it lists and searches nothing beneath it, takes nothing in, and names the region underincompletewith the mount that holds it and the path to use; and a sync namingsub/ctakes its change in;grepnaming it takes nothing in, and it stays when it's deleted on disk and when the directory itself is gone;grepnaming it takes nothing in, and its deletion is taken where a walk lists its directory;./a/b/x,a/c/../b/xanda//b/xare refused asa/b/xis, byread,readFileFromDisk,readBinaryFromDiskandresolveAbsolutePath, whilea/b/../x.txtanda/b/../../top.txtread the outer mount's files from disk; the nested-mount check readsa//b,a/b/,./a/banda/c/../basa/b, anda/b/../xanda/bcas not; a sync orlsofa//b,a/b/ora//b/inner.txttakes nothing in, and neither a barematerializenormaterializeMountwrites an outer copy keyeda//b/inner.txt; an outer copy there, with a conflict and a workspace deletion recorded beside it, is counted underpassedOverand notincompleteby a sync without a path, isn't counted bystatusor taken up by a barematerialize, and is listed bylsandglobwith a note naming its holder; amaterializenaminga/b,a/b/,a//b/inner.txtora/b/inner.txt, forced, pushes nothing and names the holder; on mounts configured with a trailing separator, the refusal's path lists and reads, and on mounts configured with relative paths, reads, listings, the refusal's path and a generated file all work; a forcedmaterializeof an ancestor leaves an outer copy out; agrepnaming a file there that the outer store doesn't hold names its holder too; a path two mounts deep is named by its innermost mount;grepof the outer mount returns nothing of a copy its store holds there, and passes over the directory by design, while agrepnaming the directory, the file ora//b/inner.txtnames the holder; no push of the outer mount writes a copy its store holds there: a barematerializeleaves it out, a named one, forced or not, names the mount that holds it, andmaterializeMountskips it; a nested mount beneath a directory anignoreentry covers is still another mount's directory to a sync that names a path;ls, recursive or not, agloband agrepthat name a directory passed over by an editor's name, a bare-nameignoreentry or a path entry, or a scope beneath one, take nothing in there, name the region and track nothing, while a sync naming it syncs what's there;read, aneditand adeleteof a file the store doesn't hold, beneath a directory anignoreentry covers, covered by one by its own name, or an editor's backup file, work as onmain: the read returns it, the edit and the deletion reach disk, and a sync without a path then reports what they took in underskipped; a file tool naming a path in a nested mount's directory beneath an ignored one is refused, and the lazy pass, reached past the refusal, takes nothing in; aneditof an ignored path whose adoption is pending works from disk's later change, which the settling pass takes in first; adeleteof such a path deletes disk's later copy, which its settling pass takes in first, so amaterializewithapplyDeletionsunlinks it;test/workspace-watcher.test.ts: under a directory with a dot in its name, on the native and polling backends, the watcher reports plain names,report.v2.swpandnotes.sublime.tmplincluded, and passes over editors' files and a directory named like a backup; it passes over what the ignore list covers; and, given a nested mount's directory, it reports a file beside it and nothing beneath it, on both backends. Each waits for what it must report before checking what it mustn't.workspace:deleted, thenworkspace:created); a new copy written in two parts 300 ms apart, after an editor's rename aside, was taken in as its first part, then whole (twoworkspace:modifiedwakes).Runs, on Linux with Node 24.22.0, from a clean checkout:
00d61d23, which mergesmainatb96e918b(fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456, fix(history): cut snippets and indexed text without splitting a surrogate pair #410 and fix(tune-out): a tune-out timer whose work throws reports it instead of ending the process #455) into3bbf1aee:tscand the build pass.workspace-three-waypasses 243 of 243,workspace-watcher17 of 17,workspace-file-tools35 of 35 andworkspace-glob-unclosed-brace6 of 6, each file run alone under node's default isolation and again in one process. Taking the name off one file tool's pass at a time fails at least one test each:ensureSynced's,settleBeforeMutation's, and the passeditmakes of its own since fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456.3bbf1aee, before that merge:tscand the build pass, andworkspace-three-waypasses 242 of 242 andworkspace-watcher17 of 17, both ways.3bbf1aee, with049d2175's two passes, which named no path, four of the new tests fail: the three file-tool tests and the pending adoption'sedit. Taking either pass'snamedout alone fails its own tests, and letting a named pass observe another mount's directory fails the nested-mount test and three earlier ones.049d2175, withmain's workspace sources in place (observe.ts,reconcile.ts,index.ts,watcher.tsandtypes.tsat3af13bf4), the build fails on the tests' use of what this change adds, soworkspace-three-waydoesn't load, and 5 ofworkspace-watcher's 17 fail: the plain-name and editor-file cases and the nested-mount cases, natively and polling, and theignorelist read by the path relative to the mount.049d2175, each piece the earlier rounds added or changed, taken out alone, fails at least one test: the nested-mount check reading a path lexically, another mount's region counted as passed over, other mounts' paths left out ofmaterializeand the counts, the notes and regions naming the holder, a namedmaterializeand a namedgrepnaming the holder,status's pending intents, andparsePath's andgeneratedFileKey's resolved root.Not changed
main; we ran nothing on one. Every lexical rule of the workspace (mount containment, the ignore list, the nested-mount list) compares case as given.main.parsePathreturns as written, as onmain: a path that climbs above the mount's root and back in (work/../work/a/b), a..after a nested directory's name in a listing's or a sync's scope (work/a/b/..), and a trailing or doubled separator in amaterialize's path (work/a/). The nested-mount checks read..within a path, but a scope's prefixes are judged as written, sols work/a/b/..names the nested mount's region rather than listinga, with the advice a directory passed over by design gets, and a sync naming that spelling fails on chronicle's refusal of a..in a tree path. A separator right after the mount's name (work//a/b) is refused as traversal, as onmain. Making every tool's path canonical atparsePathis a separate change.materializewrites back. A branch's history can still hold an editor's file or an ignored path, and materializing it writes the file, as for any file the workspace holds.\: on Windows, where\is also a separator, the checks this change adds read only/as one. The nested-mount check normalizes a path withposix.normalize, and the checks of what's passed over by design split it on/, while the file operations' refusal resolves it as the platform does. So a sync,ls,globorgrepnamingwork/a\bisn't read aswork/a/bis, and by our reading reaches the nested mount's directory there, as every spelling did onmain. None of us ran the module on Windows.Written by Nell-1783 (Claude Opus 5.5). Ada-1017 decided its design, Oscar-1808 ruled on its scope, and Jonah-2291 read its last four rounds whole and this text against
3bbf1aee. Ogden-2688 read its merge ofmain,00d61d23. Our internal review read every round but the last two: one answering that review's findings, and one answering Greptile's. Description checked and posted by Lottie-2661 through the sharednissa-seruaccount.