Skip to content

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

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:nell/workspace-editor-files
Oct 18, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:nell/workspace-editor-files

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A running watcher and a sync without a path now agree about what a mount leaves out: the mount's ignore list, 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 _config mount, 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

  • The ignore list never reached the watcher. MountWatcher handed chokidar config.ignore as 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 a paths pass, which has no walk, so nothing in it reads the list either (reconcile.ts:629-630 at 3af13bf4). On 0.22.1, with ignore: ['*.log', 'cache/**'] and watch: 'always', a .log file written and one edited while watched, and a file written under cache/, were all taken into the tree. main's watcher.ts is unchanged since 0.22.1, and the red-first run shows its watcher still reporting what the list covers.
  • Editors' files went the other way. By our reading of chokidar, its atomic option, 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.swp or gate.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.swp was reported under a root with no dot in its path, and under .local/work, main's watcher reported only kept.txt of notes.swp, report.v2.swp, notes.sublime.tmpl and kept.txt.

Changes

  • One rule says what's passed over by design, and the walk and the watcher both read it:
    • the mount's ignore list, read as before;
    • editors' swap and backup files: .name.swp and .name.swx as the whole name (vim), and names ending in ~ (backups) or in .subl…tmp (Sublime Text's saves in progress). These are the families chokidar's atomic option 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 names gate.json's swap file .gate.json.swp, and the others to the name's end. So report.v2.swp and notes.sublime.tmpl are someone's files, taken in as main's walk takes them. A directory with such a name is passed over with what lies beneath it, as main's watcher already did.
  • The walk passes over an editor's file as it passes over an ignored one: a sync counts it under passedOver as "an editor's swap or backup file", and a tracked copy is reported under incomplete, and under skipped as one disk couldn't be checked for, as main reports a tracked copy the ignore list covers. It isn't decided.
  • A walk that isn't named checks its own scope first. When the scope, or a directory above it, is passed over by design, it takes nothing in there and names the scope as that region, adding that a sync that names the path takes in what's there, editors' files aside. Only the region carries that advice: the reasons given for each file passed over are the bare ones. A name rule, an editor's name or a bare-name ignore entry such as node_modules, matches the directory but not what lies beneath it, so on main an ls, glob or grep naming such a directory listed it and started tracking what it found. A path rule such as cache/** 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).
  • The watcher gives chokidar a function that asks that rule of the path relative to the mount, and turns atomic off, so the rule is the only filter. With atomic off, an editor's save by rename arrives as an unlink at once, and as an add once awaitWriteFinish finds 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, with awaitWriteFinish, 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.
  • A mount nested inside another is that mount's directory, which every pass of the outer mount, named or not, and its watcher pass over as "another mount's directory". It's kept in a list of its own on the mount's view, apart from the outer mount's ignore, which is left as configured. On main the constructor added the nested path to the outer mount's ignore as 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 as sub/c. And since a sync that names a path sets ignore aside 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 under ignored, 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 an ignore entry 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's startsWith('..') took it for one outside.
  • The outer mount neither writes nor reads a path in a nested mount's directory. A push of the outer mount (materialize, materializeMount), forced or not, skips such a path; a materialize that names one lists it under skipped, naming the mount that holds it and the path to use there, as a sync that names the path does under ignored. 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; readFileFromDisk gives the code other_mount, and resolveAbsolutePath returns null. They judge the path lexically where it lands, so ./a/b/x, a/c/../b/x and a//b/x are refused as a/b/x is, and a/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 and grep'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. grep returns none of the outer mount's copy of such a path, and ls and glob list it with a note naming the mount that holds it and the path to use. On main a 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.
  • Copies the outer mount already holds there are passed over by design. Where main's a/b mistake 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 under passedOver, not incomplete, so a sync that reports nothing incomplete has looked everywhere it should; and a bare materialize, one naming an ancestor, and status's pending changes, conflicts and workspace deletions leave them out (fileCount counts 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.
  • parsePath and generatedFileKey judge 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. On main a root configured as a/b/ refused its own root as traversal, and a relative root refused every path.
  • A sync that names an editor's file still syncs it, as one naming an ignored path does (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), and lists it under ignored with its own note.
  • A file tool names the path it acts on, as a sync naming it does (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). read, edit, delete and readBinary take in a file the mount passes over by design that the workspace doesn't hold, as main does. The pass that write, edit, delete and writeBinary make 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.
  • The sync tool's description and 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 grep naming 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 a grep names 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.

  • The watcher's ignored as 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.
  • The editor rule in the walk. The two sides could also agree by both taking these files in, but every mount would then version swap files and write them back, which is that issue's symptom on any mount. It costs one regex and its reason text, anchored as described above.
  • 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.
  • Directories with such names passed over, as main's watcher already passed drafts~/ over. It costs nothing beyond the rule.
  • Nested mounts as a list of their own, with the guard on what counts as nested. It stops the outer mount from taking a deeper mount's files, from passing over sub/c because of a mount at c, 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.
  • The push's skip and the file operations' refusal. Without the skip, the outer mount's push writes its own copy over the nested mount's file. Without the refusal, a write through the outer mount would report success and never reach disk, and a read would return a copy the outer mount no longer keeps current. Each costs one check of the path against the list. The refusal judges the path where disk access lands it, since a check of the path as typed let ./a/b/x read 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 and grep asks, keeps them all on the spelling the refusal judges, except as Not changed says: a check of the path as typed let a sync of a//b take the nested mount's file in under a//b/inner.txt, which chronicle accepts as a tree path. grep leaves out the outer mount's copy of such a path, since it would return that copy's content, which read refuses.
  • 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, and grep leaves that report out. Without the check, a grep naming 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.
  • Copies the outer mount holds there passed over by design, and left out of its pushes and pending counts. No tool of the outer mount syncs, pushes or clears them, so naming their directory under incomplete never cleared, a bare materialize named them as skipped every time, and status counted them as pending for good. It costs one check of each path against the list, in the selection, the evidence's entries and status, and one reason the walk's region doesn't count as unreached.
  • Containment judged against the mount's resolved root, in parsePath and generatedFileKey. 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 one resolve of the root.
  • gather leaving 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, a grep naming 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.
  • A file tool's passes naming the path it acts on. An agent that names one file is asking for that file, as a sync naming it is (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). Without it, read, edit and delete of such a file the workspace didn't hold answered that disk "could not be checked", where main read, edited or deleted it, and an edit with 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.
  • The scope check in the walk. Without it, a listing inside a directory passed over by a name tracks what the rule leaves out, the thing the ignore list is there to prevent. It costs one check before the walk, and naming that region in the pass's answer.
  • Cut: the dedupe of the nested-mount entry, which main needed so as not to add the same pattern to ignore twice. A list of its own is only asked whether a path lies in one of its directories, so a repeated entry changes nothing.
  • Left as they are: other editors' names, such as vim's .swo and Emacs's #name#, which both sides now take in alike. Which other names count as an editor's is a separate question. The mount include list, a separate change, is what lets _config hold 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:
    • a sync passes over editors' files by name at any depth, and a directory named like a backup, counted once, and takes notes.swp, report.v2.swp, backup.txt.bak, notes~draft.md, notes.sublime.tmpl, a.subl.tmp.csv and .cfg/notes.swp; a sync naming a path beneath that directory syncs it, with its note;
    • a stored copy behaves as one the ignore list covers;
    • a sync without a path of the outer mount counts its three nested mounts under passedOver as another mount's directory and takes none of their files, with an a/b listed by hand and a mount at ..cache among them, and takes entries that only share a nested mount's name, cat.txt beside a mount at c among them; a sync naming a nested mount's directory, or a file beneath it, takes nothing in and says why under ignored, naming the mount that holds it and the path to use, a directory's walk naming the region and a file making none; an ls, glob or grep of it lists and searches nothing beneath it, takes nothing in, and names the region under incomplete with the mount that holds it and the path to use; and a sync naming sub/c takes its change in;
    • a copy stored beneath a directory passed over by design, the ignore list's or an editor-named one, is unobserved there, a change on disk is passed over, a grep naming it takes nothing in, and it stays when it's deleted on disk and when the directory itself is gone;
    • a copy the ignore list or an editor's name covers by its own name: a change on disk is passed over, a grep naming it takes nothing in, and its deletion is taken where a walk lists its directory;
    • a sync naming a directory keeps a copy stored beneath a passed-over directory inside it, whether that directory is there and changed or gone, and a sync naming the passed-over directory itself takes its deletion;
    • every file operation of the outer mount refuses a path in a nested mount's directory, naming the mount and the path to use, and the outer store gains nothing there; ./a/b/x, a/c/../b/x and a//b/x are refused as a/b/x is, by read, readFileFromDisk, readBinaryFromDisk and resolveAbsolutePath, while a/b/../x.txt and a/b/../../top.txt read the outer mount's files from disk; the nested-mount check reads a//b, a/b/, ./a/b and a/c/../b as a/b, and a/b/../x and a/bc as not; a sync or ls of a//b, a/b/ or a//b/inner.txt takes nothing in, and neither a bare materialize nor materializeMount writes an outer copy keyed a//b/inner.txt; an outer copy there, with a conflict and a workspace deletion recorded beside it, is counted under passedOver and not incomplete by a sync without a path, isn't counted by status or taken up by a bare materialize, and is listed by ls and glob with a note naming its holder; a materialize naming a/b, a/b/, a//b/inner.txt or a/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 forced materialize of an ancestor leaves an outer copy out; a grep naming 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; grep of the outer mount returns nothing of a copy its store holds there, and passes over the directory by design, while a grep naming the directory, the file or a//b/inner.txt names the holder; no push of the outer mount writes a copy its store holds there: a bare materialize leaves it out, a named one, forced or not, names the mount that holds it, and materializeMount skips it; a nested mount beneath a directory an ignore entry covers is still another mount's directory to a sync that names a path;
    • a materialize treats a stored editor's copy and an ignored path as it treats a plain file: it writes them back after a branch switch, writes a change the workspace owes while disk has the agreed copy, and leaves a change disk made as a conflict unless forced;
    • an ls, recursive or not, a glob and a grep that name a directory passed over by an editor's name, a bare-name ignore entry 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;
    • a read, an edit and a delete of a file the store doesn't hold, beneath a directory an ignore entry covers, covered by one by its own name, or an editor's backup file, work as on main: the read returns it, the edit and the deletion reach disk, and a sync without a path then reports what they took in under skipped; 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; an edit of an ignored path whose adoption is pending works from disk's later change, which the settling pass takes in first; a delete of such a path deletes disk's later copy, which its settling pass takes in first, so a materialize with applyDeletions unlinks it;
    • with both mounts' watchers running, a file written beneath a nested mount, which the inner mount takes in, never reaches the outer mount's tree, while the outer mount's own later file does;
    • with the watcher running, an editor's save by rename, written at once, reaches a wake as a change and not as a deletion, and the new file is still watched.
  • 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.swp and notes.sublime.tmpl included, 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.
  • A probe outside the suite, at this PR's head, for the slow saves the watcher's comment and the changelog describe, three runs each, with a 50 ms debounce and the watcher running: a file removed, then written again 600 ms later, was taken in as a deletion, with the store holding nothing, then as a creation (workspace:deleted, then workspace: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 (two workspace:modified wakes).

Runs, on Linux with Node 24.22.0, from a clean checkout:

  • At this PR's head, 00d61d23, which merges main at b96e918b (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) into 3bbf1aee: tsc and the build pass. workspace-three-way passes 243 of 243, workspace-watcher 17 of 17, workspace-file-tools 35 of 35 and workspace-glob-unclosed-brace 6 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 pass edit makes 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.
  • At 3bbf1aee, before that merge: tsc and the build pass, and workspace-three-way passes 242 of 242 and workspace-watcher 17 of 17, both ways.
  • At 3bbf1aee, with 049d2175's two passes, which named no path, four of the new tests fail: the three file-tool tests and the pending adoption's edit. Taking either pass's named out alone fails its own tests, and letting a named pass observe another mount's directory fails the nested-mount test and three earlier ones.
  • At 049d2175, with main's workspace sources in place (observe.ts, reconcile.ts, index.ts, watcher.ts and types.ts at 3af13bf4), the build fails on the tests' use of what this change adds, so workspace-three-way doesn't load, and 5 of workspace-watcher's 17 fail: the plain-name and editor-file cases and the nested-mount cases, natively and polling, and the ignore list read by the path relative to the mount.
  • At 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 of materialize and the counts, the notes and regions naming the holder, a named materialize and a named grep naming the holder, status's pending intents, and parsePath's and generatedFileKey's resolved root.
  • CI runs the typecheck, the build and the suite, on Node 20 and 24.

Not changed

  • Case: by our reading, on a case-insensitive filesystem, a differently cased spelling of a nested mount's directory reaches its files, as on 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.
  • A link in the outer mount that leads into a nested mount, on a mount that follows links: the outer mount reads the target as its own file, under the link's name, as on main.
  • Spellings parsePath returns as written, as on main: 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 a materialize's path (work/a/). The nested-mount checks read .. within a path, but a scope's prefixes are judged as written, so ls work/a/b/.. names the nested mount's region rather than listing a, 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 on main. Making every tool's path canonical at parsePath is a separate change.
  • What materialize writes 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.
  • Windows' \: on Windows, where \ is also a separator, the checks this change adds read only / as one. The nested-mount check normalizes a path with posix.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, glob or grep naming work/a\b isn't read as work/a/b is, and by our reading reaches the nested mount's directory there, as every spelling did on main. 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 of main, 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 shared nissa-seru account.

…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.
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5 Tier: apex

[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.

Findings

  1. P1 Ignored files cannot be opened ▶
  2. P1 Windows paths bypass mount separation ▶
Fix 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.

Summary

The 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.

  • Syncs and watchers pass over the same ignored paths and editor files.
  • Outer mounts leave nested mounts' directories to the mounts that own them.
  • Mount path checks work with relative roots and trailing separators.

Reviews (1) · Last reviewed commit: "fix(workspace): the watcher and a sync a..." · Reviewed by Greptile

Comment on lines +681 to +690
} 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

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, edit and delete of 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 edit of 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

Comment on lines +869 to +871
export function inOtherMount(relativePath: string, others: readonly string[]): boolean {
const lexical = posix.normalize(relativePath).replace(/\/+$/, '');
return others.some((dir) => lexical === dir || lexical.startsWith(dir + '/'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.
@nissa-seru
nissa-seru merged commit 083370d into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
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 to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
, 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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
… 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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…, 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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
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