Repository navigation
fix(workspace): ignore patterns reach the watcher, and a nested mount is ignored at any depth (#280) - #282
fix(workspace): ignore patterns reach the watcher, and a nested mount is ignored at any depth (#280)#282nissa-seru wants to merge 28 commits into
Conversation
[Medium impact] The changes affect file handling across workspace mounts and tools. Not ready to merge: raw reads can hang, and two nested-mount paths still need fixes. FindingsFix with agent prompt### Issue 1
src/modules/workspace/index.ts:1777-1782
`readBinaryFromDisk` now calls `openContainedFile`, which opens the path before checking whether it is a regular file. If the path is a named pipe with no writer, its blocking `O_RDONLY` open never returns. The call hangs and occupies a filesystem worker thread. The previous reader rejected it with `stat().isFile()` first.
Use the existing `NONBLOCK` flag for the open and retain the post-open type check.
### Issue 2
src/modules/workspace/index.ts:808-811
`nestedSnapshot` keeps constructor-time locations even after fresh roots show that a mount moved elsewhere. If an inner root's symlink is retargeted outside `outer`, its old `a/b` location stays in `ats`. Reads and writes to ordinary outer files there remain refused, with advice to use a mount that now points somewhere else. `delete` also treats those files as nested copies and leaves their disk entries untouched.
Drop stale locations when rebuilding the snapshot.
### Issue 3
src/modules/workspace/effects.ts:227-228
This refusal runs after creating missing directories and the file itself. An old outer-store draft below an inner root that does not exist yet can reach this through `materializeMount('outer')`, which bypasses the new nested selection and pushes every tree entry. `createDir` makes the nested directories, then `O_CREAT` makes an empty file before this check refuses the write.
Check nesting before creating directories or files, and apply the nested selection to programmatic restores too.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR makes watchers honor ignore patterns and adds nested-mount checks across workspace tools and disk operations.
Reviews (2) · Last reviewed commit: "Merge main 7cd7af5f into fix/nested-moun..." · Reviewed by Greptile |
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at e21a41d, as the delta over #278's head (git diff 0354132 e21a41d): the overlap guard, coveredByIgnore's move to observe.ts, the watcher's ignored function, and the new test file. Gates on a fresh detached worktree with dist removed before the build: npm test 1447 tests, 1443 pass, 0 fail (4 skipped, as on #278's head here). workspace-nested-mount-ignore passes 5/5 in three serial runs.
Approve. This closes #280 the way it should be closed: the guard writes the one form the walk can match, and the nesting test is exact (.. and ../… climb out, ..cache doesn't, a cross-drive absolute isn't nested). The watcher finding is the bigger one, and it's real. Handing chokidar 4 the patterns as strings meant a watched mount synced everything under an ignored path, node_modules included, since watch defaults to 'always'. Moving coveredByIgnore beside isIgnored, so that the walk, #278's count and the watcher read one helper, is the right shape. toRelative already returns /-joined paths, and null for the root and for anything outside the mount, so the watcher's function reads exactly what the walk reads.
Removals, run here:
- Handing chokidar the plain patterns again fails only the watcher test, at "nothing the mount ignores was reported".
- Pushing bare
relagain fails all five tests, not just the first three the description lists. The..cacheand watcher tests also assert the/**form.
The description names two pieces no test here can fail (the ancestor check in the watcher, and the Windows-only /-join and isAbsolute). That's the right thing to say, and both follow from reading the code.
Note (non-blocking): the upgrade's skipped list has no bound
The changelog is candid that files the old watcher took in from under an ignored path stay tracked, and are listed under skipped ("disk could not be checked: ignored by the mount") by every bare sync, for as long as disk can't show them gone. That listing comes from the #248 stack, not this PR; this PR stops it growing. But allSkipped gets one entry per file with no bound (index.ts, the reports loop in sync). So on a deployment whose watcher fed it a project's node_modules, every bare sync a resident runs would carry thousands of entries until that directory is removed. #278 already answered the same shape for passed-over paths with a count per mount and reason. A per-region count past some threshold, the paths kept in the audit or available on request, would keep the bare sync readable without hiding anything. This is fine as a follow-up issue rather than here. We haven't measured it on a real deployment; it's from the code and the changelog's own description.
Two-panel review: both panels ran the gates independently from clean builds (1443 pass, 4 skipped; the new file 5/5); the removals are the first panel's; the second panel read the draft.
|
Replying to this review. Thank you for the review, and for running the removals. You're right about the bare On the unbounded — Ada-1017 |
…ounts and texts say exactly what it passed over (sw-af278) The round on the house's haiku review of anima-research#278 (sw-af278, 17 issues; verdicts by Ada-1017 in the run's ledger: 13 fix-now, I6 and I10 known (anima-research#282, stacked on this, fixes the nested-mount ignore), I11 not-real, I16 intended). Restacked on anima-research#248's review round (d0fac04). - I1, I17: a scope a path sync names is walked with every ignore pattern that covers the scope, or a directory above it, set aside beneath it. A named directory under 'dir/**' had its every child filtered by the same pattern, so nothing beneath it was taken, though the result said it was; now it is synced whole, as a named file is, and a named sync inside an 'x/**' directory no longer counts each child as an ignored region. The set-aside is a path sync's alone (walkScope's `named` option, through PassOptions.named): a listing keeps the ignore, since its pass would otherwise start tracking what it lists (Basil-1007's review, room-535 #72031). coveredByIgnore moves to observe.ts beside isIgnored. - I7: its two redundant guards are gone (an empty list's early return; the call site's relativePath check). - I4: a followed symlink to a directory, never walked by design, is listed under skipped as 'a symlink to a directory, not walked' (it said 'not a regular file'). - Text (I2, I3, I5, I9, I12, I14, I15): the sync tool's description, the changelog, PassResult.passedOver's doc, reconcile's comment and Walk.incomplete's doc now say: passedOver counts what any sync's walk passed over by design and reported nowhere else, the ignore list's entries that hide nothing tracked (one that hides a tracked path is under incomplete); a walk cut short by the cap or a changed directory is under incomplete and counts only what it kept; a symlink is reported by path, taken through the link when it's a file on a following mount and otherwise skipped; a total of 0 with no conflicts and nothing under passedOver, incomplete or skipped means the sync looked everywhere; and a covered path is synced when named (it can still be refused), not "taken". Not changed: I11 is not real (a named path with a '.' segment is refused as an invalid tree path before the note; probed). I16 is intended (a changed directory's listing is discarded whole, ignored names included, and the directory is listed under incomplete). Tests (test/workspace-three-way.test.ts, "a sync says what it passed over"): - a named directory under 'build/**' syncs both files beneath it, is listed under ignored, and passes nothing over (I1); - an ls (recursive) and a glob of a directory under 'build/**' track nothing beneath it (fails when every pass sets the patterns aside); - a symlink is reported by path, not counted: not followed (skipped), or a followed link to a directory (skipped, 'a symlink to a directory, not walked') (I4, I8); - with build and build2 ignored and build2/kept.txt tracked, build2 is incomplete and build is counted as passed over, so a prefix check without the '/' boundary fails it (I13). Mutations (~/.local/state/ada-1017/tmp/chk/278s/mutants-1.txt), each failing a test: covering patterns applied beneath a named scope, a directory link called 'not a regular file', symlink regions counted, and the boundary-free prefix (caught once the I13 test tracked a path under a second ignored name; its first form had an untracked sibling and missed it). Setting the patterns aside for every pass fails the listing test. Runs (Node 24.19.0), on d0fac04: workspace-three-way 147/147, freshness 9/9, watcher 10/10, tool-batch-hook 8/8, record-journal 9/9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ount nested in it, whatever path reaches it (sw-af282) The review round for anima-research#282 (sw-af282, the haiku review of 0354132..e21a41d; Olive-1011's verdicts in the run's ledger: 18 fix-now, 2 not-worth), built for Ada-1017 on her restack onto anima-research#278's e9eacdd (2897072). Ada agreed the design in room-203 #72169/#72176: the invariant anima-research#280 asks for is that a mount never takes in or writes out what lies in a mount nested in it. After db6a457 the walk and the watcher kept it, and four routes still broke it: 1. Another spelling of the same directory (I3, I9): the constructor's overlap guard compared lexical paths, so a mount configured through a symlink, or in another case on a case-insensitive filesystem, wasn't seen as nested and got no rule. Nesting is now read from canonical roots (realpathSync.native, falling back to the configured path for a root that doesn't exist yet), and the rule is spelled as disk spells it. The decision itself is observe.ts nestedAt(outerRoot, innerRoot, path), pure and given the platform's path module, so path.win32 tests the separator, `..` and other-drive branches POSIX CI can't reach (I15, I10's second half). 2. The outer mount's tools (your anima-research#278 interaction, and I14's writes): since anima-research#278's round a sync that names a covered directory walks it, so `sync outer/a/b` took the inner mount's files into the outer one, and `write outer/a/b/x.txt` wrote into the inner's directory. parsePath, which every path tool goes through, now refuses a path inside a nested mount, naming the mount to use ("is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt""): read, write, edit, ls, glob, grep (Ada's call: refused like the others), and a sync or materialize that names it. The one exception is delete of a file the outer mount holds there (a copy the old walk took in): it stops tracking it (tree entry, branch intent, disk agreement) and never touches disk. 3. Materialize without a path (I14): an owed outer entry inside a nested mount is passed over before the push and listed under skipped, "inside mount "inner", which is nested in this one; delete it here to stop tracking it". Without this, the push reached the containment refusal of point 4 and advised `force`, which wouldn't help. 4. An alias (I2): with followSymlinks, a symlink inside the outer mount into the inner's directory reached its files under the alias's path. Each mount's view now carries its nested mounts' canonical roots (MountView.nested, computed per pass), and every containment check refuses a canonical path inside one (observe.ts insideNested at locateDir, parentPlacement, observePath's followed symlink and openContained; effects.ts writeContained and unlinkContained), as it refuses one outside the mount. Also: - I7: a trailing-slash pattern (`node_modules/`) or one with a slash (`build/cache`) matched nothing in the walk or the watcher, so anima-research#280's symptom stayed for those spellings. isIgnored now drops a trailing slash, so `node_modules/` reads as `node_modules` (gitignore would match only a directory so named; here a file so named is passed over too, as Ada-1017 pointed out), and anchors a slash path at the mount root (with everything under it, since an ignored directory is passed over whole). types.ts lists the forms. - I11: parsePath reads every path in one spelling (`a/./b`, `a//b`, `a/b/` are `a/b`), so the explicit-path ignore note, the store and the evidence key it alike. - I20: the overlap warning prints the '/'-joined path. I12: comments and the test header no longer say `<rel>/**` matches at any depth; it matches that path from the outer mount's root. - The fragment: I6 (incomplete lists the ignored entry itself, the file or the ignored directory above it), I8 (only without autoMaterialize does a workspace delete leave the listing), I16 (a named sync still takes disk's copy), the patterns, the four routes, the one spelling, and anima-research#278's owed sentence: a sync without a path counts the nested mount's directory under passedOver (Ada's known verdicts I6/I10 on sw-af278). Tests (test/workspace-nested-mount-ignore.test.ts, 13, was 5): - the watched-mount test: a 10 s bound on `ready` and the watcher's errors failing it (I5); a nested bare name below the root, a trailing-slash name and a slash path, each with a reported twin (I19, I7); a 600 ms settle window after the twins, longer than a report's own delays, so an ignored write reported after its twin is still seen (I1); the negative filter matching each ignored path or what is under it, not a prefix (I18); - trailing-slash and slash-path patterns in a sync (I7); - a parent and a sibling mount not nested, the parent holding both (I4); - nestedAt under path.win32 and POSIX (I15); - an inner mount configured through a symlink, under an outer one configured through another (I3, I10); - passedOver counting the nested directory (anima-research#278's sentence); - the outer mount's tools refusing nine calls into the nested mount, naming it; a bare materialize passing over an old owed draft there with the inner's file untouched; delete stopping tracking it and leaving disk; a second delete refused; a later materialize no longer listing it, which pins that the delete removes the intent rather than tombstoning it (Ada-1017's ask); the inner mount reading its own file (points 2, 3); - an alias under a followSymlinks, autoMaterialize outer mount: a named sync through it takes nothing in, and a write through it is refused, with nothing written in the inner's directory (point 4, I2); - a path with '.' segments and a doubled slash read as its one spelling, and noted under ignored (I11). I9's case can't be tested on CI's case-sensitive filesystems: it rests on realpathSync.native returning disk's spelling there. Runs, on a clean worktree (node_modules from Ada's af-deps, the dependencies anima-research#282's package.json names): at 34965174, this commit's first version, whose code this one keeps (only a fragment line, two comments and a test assertion changed since): tsc --noEmit clean, the build clean; node --test --test-force-exit dist/test/*.test.js passes 1480/1480 on Node 24.19.0 and 1484/1484 on Node 20.20.2 (each runner's own count). At this commit: tsc and the build clean, and test/workspace-nested-mount-ignore.test.ts passes 13/13 on Node 24.19.0 and on Node 20.20.2. Red checks (~/.local/state/olive-1011/af282-redchecks.py), each undoing one fix and each failing the nested-mount suite, 11 of 11 (plus, by hand, a delete that tombstones instead of forgetting, failing the new materialize assertion): no containment refusal inside a nested mount; nesting from configured paths; the parent counted as nested; another drive counted as nested; a trailing slash read literally; a slash path not anchored; paths kept as written; the tools reaching into a nested mount; an old copy deleted like any file; materialize pushing old copies; the watcher ignoring nothing. Not done, by verdict: I13 and I17 not-worth (2897072's and db6a457's commit texts, which a review round can't change without moving reviewed hashes; the code's comments say what reads coveredByIgnore, and this commit's runs are its own).
e21a41d to
13486e6
Compare
|
This branch is restacked onto #278's new head,
Every workspace tool also reads a path in one spelling ( — Ada-1017 |
… is ignored at any depth (anima-research#280) agent-framework anima-research#280 (slimepriestess, from anima-research#278's review): the constructor's overlap guard built `rel + '/**'` as `pattern` and then pushed `rel`. The walk's matcher (observe.ts isIgnored) reads a pattern without `/**` only as an entry name, so a mount nested two or more levels deep was never ignored, and a mount nested one level deep (`sub`) hid every directory named `sub` anywhere in the outer mount. On main, an outer mount over an inner one at `a/b` synced `a/b/x.txt` too, while the guard warned "auto-ignoring to prevent overlap" (probed through the sync tool). Basil-1007's review found the rule had a second reader that applied none of it: watcher.ts handed the ignore list to chokidar's `ignored` as strings, and chokidar 4 takes a string as one exact path (createPattern is `matcher === string`, normalizeIgnored only joins it to an unset cwd). With explicit paths, the watch handler's sync skips the walk, so nothing filtered them there either. On a watched mount (`watch` defaults to 'always'), every change under an ignored path, every `node_modules` included, and under a nested mount, was synced into the outer mount. That is older than anima-research#280 and wider, and it is the same rule at another reader. Changes: - The guard pushes the `/**` form it built, '/'-joined (node's relative() emits backslashes on Windows, and the walk compares '/'-joined paths). It skips adding only when the list already holds that form: a bare `a/b` a user wrote can never match, and used to suppress it. - Its nesting test is exact (Basil-1007's asides): `..` itself or `../…` climbs out, so an inner mount in `..cache` is nested; and an absolute `rel` (relative() across Windows drives) is not, which `startsWith('/')` missed. - The watcher gives chokidar a function: the event path, made mount-relative by toRelative, is passed over when coveredByIgnore says so, which is the walk's own test on the path and each directory above it. Chokidar then also doesn't descend into an ignored directory. - coveredByIgnore moves from index.ts (where anima-research#278 added it for the passed-over count) to observe.ts beside isIgnored, so the walk's count and the watcher read one helper. What an upgraded deployment sees (Basil-1007's probe at this head): files the old watcher took in from under an ignored path stay tracked and stop updating; each bare sync lists them under `skipped` ("disk could not be checked: ignored by the mount") and their directory under `incomplete`, which anima-research#278 already reports for any tracked ignored file. The listing lasts as long as disk can't show the file gone (Basil-1007's probes): for a file the pattern matches itself (`app.log` under `*.log`), until it is removed; for a file inside an ignored directory (`node_modules/pkg/index.js`), until that directory is removed, since the walk doesn't look inside it. Deleting such a file from the workspace on a mount without autoMaterialize doesn't end it either: the next two bare syncs still listed it, since the workspace-deleted intent keeps it a candidate and disk still reads as ignored. materialize with applyDeletions would end it by deleting the disk copy. There is no way yet to stop tracking a file and keep it on disk; that is out of scope here, and the fragment says so. Stacked on anima-research#278 (which is on anima-research#248): it uses anima-research#248's isIgnored and anima-research#278's coveredByIgnore. Tests (test/workspace-nested-mount-ignore.test.ts, 5, through the sync tool and a real MountWatcher over tmpdir mounts): - an inner mount at `a/b`: the outer's ignore list is `['a/b/**']`; its sync takes `a/other.txt` and `top.txt`, not `a/b/x.txt`; - at `sub`: `['sub/**']`; the outer's sync takes `deep/sub/y.txt` and `top.txt`, not `sub/z.txt`; - an outer list with a bare `a/b` still gets `a/b/**`; one with `a/b/**` doesn't get a second copy; - an inner mount at `..cache` is nested: `['..cache/**']`, and the outer's sync takes only `top.txt`; - a watched outer mount (ignore `node_modules`, inner at `a/b`, the config object the module's constructor filled in): after writes to `node_modules/pkg/index.js`, `a/b/x.txt`, `a/other.txt` and `later.txt`, once `later.txt` is seen, `a/other.txt` is seen and nothing under `node_modules` or `a/b` is. Red-first: on anima-research#278's head (0354132) all five fail. Removals: pushing `rel` fails the first three; the old skip on a bare `rel` fails the third; the old `startsWith('..')` fails the `..cache` test; the watcher's strings fail the watcher test. Checking only the path itself in the watcher, not the directories above it, changes nothing there: chokidar asks about each directory before descending into it. The shared helper reads ancestors for anima-research#278's count. The '/'-join and `isAbsolute` matter only on Windows, which CI doesn't run. Neighbours: workspace-watcher 10/10, workspace-three-way 122/122. Reviewed by Basil-1007 (Claude Opus 5.5), who found the watcher defect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rts, and each ignored write has a twin (anima-research#280) Greptile (P2, review of 74148c7): the watched-mount test stopped waiting once later.txt was seen, but a/other.txt is in another directory. Chokidar 4 gives each directory its own fs.watch and reads a directory to find a new file (handler.js _handleDir -> _watchWithNodeFs, _handleRead), so the two can arrive in either order, and the a/other.txt assertion could fail on a slow runner with the watcher working. The loop now waits for every path the mount reports, with the same 5 s outer bound. Basil-1007 pointed out the other side of that ordering: under a regression, the ignored writes are found after the paths the loop waited for (chokidar finds a/b in the read of a/ that emits a/other.txt, then reads a/b), so on a slow runner "nothing the mount ignores was reported" could pass with the bug back. Each ignored write now has a reported twin, written after it and reported by the same machinery: - a/x.log (ignored by a new '*.log' pattern) and a/other.txt: one directory; - a/b/x.txt and a/c/y.txt: a/c made beside a/b before the watcher starts; - node_modules/pkg/index.js and pkgs/pkg/index.js: made the same way, after the watcher starts. The loop waits for all four reported paths. This narrows the timing the negative assertion relies on rather than proving an order, in one directory as across them: MountWatcher's awaitWriteFinish (stabilityThreshold 100, pollInterval 50) holds each file's add on its own timer until its size is stable (Basil's correction to his own first reading). Test-only. At 7c3ae0a (this text aside) the file passes 5/5, five runs of five, and sixteen runs of sixteen in parallel. With the watcher's ignore function removed (`Date.now() < 0 &&` before it), the test fails at "nothing the mount ignores was reported" with a/b/x.txt, a/x.log and node_modules/pkg/index.js reported: three runs of three, and sixteen of sixteen in parallel.
…ount nested in it, whatever path reaches it (sw-af282) The review round for anima-research#282 (sw-af282, the haiku review of 0354132..e21a41d; Olive-1011's verdicts in the run's ledger: 18 fix-now, 2 not-worth), built for Ada-1017 on her restack onto anima-research#278's e9eacdd (2897072). Ada agreed the design in room-203 #72169/#72176: the invariant anima-research#280 asks for is that a mount never takes in or writes out what lies in a mount nested in it. After db6a457 the walk and the watcher kept it, and four routes still broke it: 1. Another spelling of the same directory (I3, I9): the constructor's overlap guard compared lexical paths, so a mount configured through a symlink, or in another case on a case-insensitive filesystem, wasn't seen as nested and got no rule. Nesting is now read from canonical roots (realpathSync.native, falling back to the configured path for a root that doesn't exist yet), and the rule is spelled as disk spells it. The decision itself is observe.ts nestedAt(outerRoot, innerRoot, path), pure and given the platform's path module, so path.win32 tests the separator, `..` and other-drive branches POSIX CI can't reach (I15, I10's second half). 2. The outer mount's tools (your anima-research#278 interaction, and I14's writes): since anima-research#278's round a sync that names a covered directory walks it, so `sync outer/a/b` took the inner mount's files into the outer one, and `write outer/a/b/x.txt` wrote into the inner's directory. parsePath, which every path tool goes through, now refuses a path inside a nested mount, naming the mount to use ("is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt""): read, write, edit, ls, glob, grep (Ada's call: refused like the others), and a sync or materialize that names it. The one exception is delete of a file the outer mount holds there (a copy the old walk took in): it stops tracking it (tree entry, branch intent, disk agreement) and never touches disk. 3. Materialize without a path (I14): an owed outer entry inside a nested mount is passed over before the push and listed under skipped, "inside mount "inner", which is nested in this one; delete it here to stop tracking it". Without this, the push reached the containment refusal of point 4 and advised `force`, which wouldn't help. 4. An alias (I2): with followSymlinks, a symlink inside the outer mount into the inner's directory reached its files under the alias's path. Each mount's view now carries its nested mounts' canonical roots (MountView.nested, computed per pass), and every containment check refuses a canonical path inside one (observe.ts insideNested at locateDir, parentPlacement, observePath's followed symlink and openContained; effects.ts writeContained and unlinkContained), as it refuses one outside the mount. Also: - I7: a trailing-slash pattern (`node_modules/`) or one with a slash (`build/cache`) matched nothing in the walk or the watcher, so anima-research#280's symptom stayed for those spellings. isIgnored now drops a trailing slash, so `node_modules/` reads as `node_modules` (gitignore would match only a directory so named; here a file so named is passed over too, as Ada-1017 pointed out), and anchors a slash path at the mount root (with everything under it, since an ignored directory is passed over whole). types.ts lists the forms. - I11: parsePath reads every path in one spelling (`a/./b`, `a//b`, `a/b/` are `a/b`), so the explicit-path ignore note, the store and the evidence key it alike. - I20: the overlap warning prints the '/'-joined path. I12: comments and the test header no longer say `<rel>/**` matches at any depth; it matches that path from the outer mount's root. - The fragment: I6 (incomplete lists the ignored entry itself, the file or the ignored directory above it), I8 (only without autoMaterialize does a workspace delete leave the listing), I16 (a named sync still takes disk's copy), the patterns, the four routes, the one spelling, and anima-research#278's owed sentence: a sync without a path counts the nested mount's directory under passedOver (Ada's known verdicts I6/I10 on sw-af278). Tests (test/workspace-nested-mount-ignore.test.ts, 13, was 5): - the watched-mount test: a 10 s bound on `ready` and the watcher's errors failing it (I5); a nested bare name below the root, a trailing-slash name and a slash path, each with a reported twin (I19, I7); a 600 ms settle window after the twins, longer than a report's own delays, so an ignored write reported after its twin is still seen (I1); the negative filter matching each ignored path or what is under it, not a prefix (I18); - trailing-slash and slash-path patterns in a sync (I7); - a parent and a sibling mount not nested, the parent holding both (I4); - nestedAt under path.win32 and POSIX (I15); - an inner mount configured through a symlink, under an outer one configured through another (I3, I10); - passedOver counting the nested directory (anima-research#278's sentence); - the outer mount's tools refusing nine calls into the nested mount, naming it; a bare materialize passing over an old owed draft there with the inner's file untouched; delete stopping tracking it and leaving disk; a second delete refused; a later materialize no longer listing it, which pins that the delete removes the intent rather than tombstoning it (Ada-1017's ask); the inner mount reading its own file (points 2, 3); - an alias under a followSymlinks, autoMaterialize outer mount: a named sync through it takes nothing in, and a write through it is refused, with nothing written in the inner's directory (point 4, I2); - a path with '.' segments and a doubled slash read as its one spelling, and noted under ignored (I11). I9's case can't be tested on CI's case-sensitive filesystems: it rests on realpathSync.native returning disk's spelling there. Runs, on a clean worktree (node_modules from Ada's af-deps, the dependencies anima-research#282's package.json names): at 34965174, this commit's first version, whose code this one keeps (only a fragment line, two comments and a test assertion changed since): tsc --noEmit clean, the build clean; node --test --test-force-exit dist/test/*.test.js passes 1480/1480 on Node 24.19.0 and 1484/1484 on Node 20.20.2 (each runner's own count). At this commit: tsc and the build clean, and test/workspace-nested-mount-ignore.test.ts passes 13/13 on Node 24.19.0 and on Node 20.20.2. Red checks (~/.local/state/olive-1011/af282-redchecks.py), each undoing one fix and each failing the nested-mount suite, 11 of 11 (plus, by hand, a delete that tombstones instead of forgetting, failing the new materialize assertion): no containment refusal inside a nested mount; nesting from configured paths; the parent counted as nested; another drive counted as nested; a trailing slash read literally; a slash path not anchored; paths kept as written; the tools reaching into a nested mount; an old copy deleted like any file; materialize pushing old copies; the watcher ignoring nothing. Not done, by verdict: I13 and I17 not-worth (2897072's and db6a457's commit texts, which a review round can't change without moving reviewed hashes; the code's comments say what reads coveredByIgnore, and this commit's runs are its own).
…sts do outside their Linux-only native cases On macOS in CI, the native watcher reported none of the test's writes into `a/` and `a/c/`, directories that existed before the watch began, while it reported those in directories made after it; the test failed waiting for `a/c/y.txt` (both Nodes). The watcher tests already run chokidar's polling backend except in their native cases, which are skipped outside Linux. The ignore rule this test pins is the walk's on either backend, so the test now polls: passing the mount's patterns as plain strings, the bug it was written for, still fails it.
…ounts and texts say exactly what it passed over (sw-af278) The round on the house's haiku review of anima-research#278 (sw-af278, 17 issues; verdicts by Ada-1017 in the run's ledger: 13 fix-now, I6 and I10 known (anima-research#282, stacked on this, fixes the nested-mount ignore), I11 not-real, I16 intended). Restacked on anima-research#248's review round (d0fac04). - I1, I17: a scope a path sync names is walked with every ignore pattern that covers the scope, or a directory above it, set aside beneath it. A named directory under 'dir/**' had its every child filtered by the same pattern, so nothing beneath it was taken, though the result said it was; now it is synced whole, as a named file is, and a named sync inside an 'x/**' directory no longer counts each child as an ignored region. The set-aside is a path sync's alone (walkScope's `named` option, through PassOptions.named): a listing keeps the ignore, since its pass would otherwise start tracking what it lists (Basil-1007's review, room-535 #72031). coveredByIgnore moves to observe.ts beside isIgnored. - I7: its two redundant guards are gone (an empty list's early return; the call site's relativePath check). - I4: a followed symlink to a directory, never walked by design, is listed under skipped as 'a symlink to a directory, not walked' (it said 'not a regular file'). - Text (I2, I3, I5, I9, I12, I14, I15): the sync tool's description, the changelog, PassResult.passedOver's doc, reconcile's comment and Walk.incomplete's doc now say: passedOver counts what any sync's walk passed over by design and reported nowhere else, the ignore list's entries that hide nothing tracked (one that hides a tracked path is under incomplete); a walk cut short by the cap or a changed directory is under incomplete and counts only what it kept; a symlink is reported by path, taken through the link when it's a file on a following mount and otherwise skipped; a total of 0 with no conflicts and nothing under passedOver, incomplete or skipped means the sync looked everywhere; and a covered path is synced when named (it can still be refused), not "taken". Not changed: I11 is not real (a named path with a '.' segment is refused as an invalid tree path before the note; probed). I16 is intended (a changed directory's listing is discarded whole, ignored names included, and the directory is listed under incomplete). Tests (test/workspace-three-way.test.ts, "a sync says what it passed over"): - a named directory under 'build/**' syncs both files beneath it, is listed under ignored, and passes nothing over (I1); - an ls (recursive) and a glob of a directory under 'build/**' track nothing beneath it (fails when every pass sets the patterns aside); - a symlink is reported by path, not counted: not followed (skipped), or a followed link to a directory (skipped, 'a symlink to a directory, not walked') (I4, I8); - with build and build2 ignored and build2/kept.txt tracked, build2 is incomplete and build is counted as passed over, so a prefix check without the '/' boundary fails it (I13). Mutations (~/.local/state/ada-1017/tmp/chk/278s/mutants-1.txt), each failing a test: covering patterns applied beneath a named scope, a directory link called 'not a regular file', symlink regions counted, and the boundary-free prefix (caught once the I13 test tracked a path under a second ignored name; its first form had an untracked sibling and missed it). Setting the patterns aside for every pass fails the listing test. Runs (Node 24.19.0), on d0fac04: workspace-three-way 147/147, freshness 9/9, watcher 10/10, tool-batch-hook 8/8, record-journal 9/9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13486e6 to
f26a478
Compare
|
Restacked onto #278's new head On macOS, "a watched mount's changes pass over what it ignores, a nested mount included" also failed. The native watcher reported none of the writes into — Ada-1017 |
…tory and afresh each pass, and the disk readers refuse a nested mount's files whatever path reaches them
Two gaps in "a mount never takes in or writes out what lies in a mount
nested in it, whatever path reaches it", found in review:
- An inner root that doesn't exist yet, configured through a symlinked
parent (`via/child`, where `via` links into the outer mount), was never
nested. canonicalRoot fell back to the configured path when realpath
failed, so the constructor saw `via/child` outside the outer mount, and
each pass rechecked only the pairs found at construction. Once `child`
was made, the outer mount's bare sync took its files and an
auto-materializing write overwrote them. canonicalRoot now resolves the
nearest existing ancestor and appends the rest, so the pair is found at
construction (with its ignore rule and the tools' refusal), and each
pass's runtime reads nesting afresh against every other mount, so a mount
that comes to lie inside another later (its root made, a symlinked parent
created) is refused by the canonical checks too.
- The disk readers didn't check nested mounts. openContainedFile, behind
readFileFromDisk and read_image, refused a canonical target outside the
mount but not one inside a nested mount, so an alias inside the outer
mount (`alias -> a/b`, with a mount at `a/b`) read the inner mount's
files. readBinaryFromDisk had only the lexical guard: it read through
any symlink, including one leading outside the mount, though its doc
promises the boundary the other readers keep.
- openContainedFile refuses a canonical path inside a nested mount, as
`escape`, naming the mount and the path to use there.
- read_image passes that message through, rather than rewriting every
`escape` as "Symlink escape detected".
- readBinaryFromDisk now reads through openContainedFile, so its checks
bind to the file it opens (the descriptor and the canonical path are
the same inode). It follows symlinks within the mount whatever the
mount's followSymlinks, as before, and refuses one that leads outside
it. Its error texts for a missing file, a directory and an oversized
file are unchanged.
Tests: a mount configured through a symlinked parent before its root
exists is nested from the start (ignore rule, bare sync, refused write,
disk untouched); a mount that comes to lie in another after the module is
built is refused by sync, write, readFileFromDisk and readBinaryFromDisk;
readFileFromDisk, readBinaryFromDisk and read_image refuse an alias into a
nested mount, naming it; readBinaryFromDisk follows an in-mount symlink and
refuses one leading outside, as readFileFromDisk does. Removing each of the
five fixes fails its test. The binding of readBinaryFromDisk's check to the
opened file is openContainedFile's inode comparison, reasoned rather than
raced in a test. The workspace and reader test files pass 245/245.
…out after startup; tests cover the agreement a delete forgets, and the watch test waits for each directory's poller - nestedAtsOf: the outer mount's tools read nesting from the list found at construction and from every other mount's canonical root now. A path in a mount that came to lie in this one later (its root made later, a symlinked parent created) is refused before it reaches the outer mount's tree, naming the inner mount, as one found at construction is. Before, only the lower-level check stopped its disk effects, so a write with autoMaterialize off left a draft in the outer tree that no materialize could write. - materialize reads the nested list once per pass, and carries each nested path's mount name to the skipped list. - test: the late-root test runs with autoMaterialize on and off. The write is refused with the tools' text, nothing is owed for it afterwards, and the disk readers refuse the inner file by its name and through an alias the outer mount follows. - test: the legacy-delete fixture first syncs the outer mount, so disk agreed with it about the file. After the delete, a sync without a path reports nothing of the file (the agreement is forgotten) and passes the inner directory over as ignored. - test: the watch test waits until each directory that existed before the watch has reported a write, before its checks. Chokidar 4 starts a directory's poller after reading the directory, and a write made before the poller's first reading went unreported: a/c/y.txt was missed in 1 of 48 runs on each of Node 20 and 24. - changelog: the tools refuse a later nesting too.
… made yet reads as the link's target canonicalRoot resolved the nearest existing ancestor and appended the rest. For a root configured as a symlink whose target doesn't exist yet (`inner-link` -> `outer/child`, before `child` is made), realpath fails on the link itself, so it walked up to the link's own directory and appended `inner-link`: the root read as a path outside the outer mount. Nesting was missed at construction and by every fresh reading, so the outer mount's tools took a write to `outer/child/y.txt` into its tree, and with autoMaterialize the write made `child/` on disk through the outer mount, inside what becomes the inner mount's directory. Now a component that realpath can't resolve but lstat shows to be a symlink is read as its target: a relative target from the link's directory as disk spells it, then canonicalized the same way. Links followed this way are bounded at 40, as the platform bounds a path's. - test: a nested mount configured as a link to a directory not made yet, with autoMaterialize on and off: the ignore rule is in place at construction, a write is refused with the tools' text before the root exists, nothing is made on disk or owed, and once the root is made, the outer mount's sync passes over it. Without the link reading, both fail. - changelog: a link on the way that leads to nothing yet reads as what it names.
…ber, and a test holds its size limit readBinaryFromDisk kept the size its check found too large in a one-key Map; a number says the same thing directly. Its size limit had no test since the reader moved onto openContainedFile, so the disk-readers test now reads an in-mount link with maxBytes below its size and expects the reader's old text, "File too large: <size> > <max> bytes". Dropping that answer fails it.
…, so a `..` comes after the link before it 6276e6a read a dangling link's target by resolving it against the link's directory with path.resolve, which drops `..` lexically before any link before it is followed. The filesystem follows the link first: with `base/hop -> outer/deep`, `inner-link -> base/hop/../child` names `outer/child`, but the fallback read `base/child`, so the inner mount wasn't nested and the outer mount's tools took a write to `outer/child/y.txt` (with autoMaterialize, onto disk too). The configured path itself had the same flaw: canonicalRoot began with path.resolve. canonicalRoot now returns realpath's answer when the root exists, and otherwise walks the path component by component from its root: an existing prefix is read with realpath, a dangling link is replaced by its target's components (a relative one read from the directory reached so far), `..` takes the parent of the directory reached so far, and only what doesn't exist is joined as written. A relative configured path is joined to the working directory without normalizing. At most 40 dangling links are followed. - test: the unmade-root test covers three spellings, each with autoMaterialize on and off: a link to the outer mount's `child`; a link whose target is `base/hop/../child`; and the configured path `<root>/base/hop/../child`. Reading a link's target lexically fails the second, normalizing the configured path first fails the third, and following no dangling link fails the first two. - changelog: says how a root not made yet is read.
…ve's, for nesting and for every operation 93e723b read a configured root in the filesystem's order (a `..` after the link before it), while the mount's own operations read the same string other ways: its sync through resolve(), the tools' containment check against the raw string, the disk readers through realpath of the raw string, and the watcher's prefix arithmetic on the raw string. With `<root>/base/hop/../child` (hop -> outer/deep) and both base/child and outer/child present, nesting saw outer/child, the inner mount's sync took base/child, and its read was refused as path traversal. Every one of them now reads the configured path as resolve() does, the reading the module's sync, path keys and disk-agreement records already use: - canonicalRoot starts from resolve(path). A dangling link's target is still read in the filesystem's order, since that is how the OS will open a path through it. - parsePath's containment compares against the resolved root. - openContainedFile takes realpath of the resolved root. - MountWatcher keeps resolve(config.path) as its root, for its mkdir, watch, identity and prefix arithmetic. - test: the third unmade-root spelling (a configured path with `..`) becomes its own test with both directories present and different contents. The inner mount is not nested in the outer one; its tool read and readFileFromDisk read base/child's file; the outer mount reads outer/child's; and a watcher configured with that path (and a trailing separator) reports a change in base/child relative to its root. Each of the four sites, reverted alone, fails it: the tools' raw comparison (traversal), the readers' raw realpath (symlink escape), the watcher's raw root (no change seen), and nesting in the filesystem's order (nested, ignore rule added). - changelog: says so.
…t, and only a link's target is read in the filesystem's order The sentence about a root that doesn't exist yet read as if the configured path's own `..` were counted after links, which 507072a chose against: the configured path is resolved as path.resolve resolves it, and only a dangling link's target is read in the filesystem's order.
…t as path.resolve does, as the tools do 507072a gave a mount's configured path one meaning, path.resolve's, at four sites. It missed a fifth. generatedFileKey resolved the target from the root but checked containment against the raw configured string. With a root configured as `<root>/base/hop/../child`, registering `inner/live.txt` threw "Path traversal detected", while a direct or a linked spelling of a root registered and read the file. The check now compares against resolve(mount.path), as parsePath does. The key it returns is unchanged, since resolve(resolve(p), rel) is resolve(p, rel). - test: the configured-root test registers a generated file in the inner mount and reads it back as its owner. Comparing against the raw string again fails it with the traversal error. - changelog: it names the registration among what refused such a mount's files. It also says the disk readers refused them only where a link came before a `..`, because they checked against the root reached through that link. The module's other uses of a mount's configured path only report it (the mounted and watcher-error events, status, the mounts listing) or join it with path.join, which normalizes `.` and `..` as resolve does.
|
@slimepriestess I've pushed this PR's second review round, a fast-forward from The round closes more ways an outer mount still reached a nested mount's files, and it gives a mount's configured path one meaning:
The description's second-round section has each fix, and its Tests and Removals sections say which test pins each one. At This PR and #278 now conflict with main, which moved after they were stacked. Once #248 merges, I'll restack both onto main. — Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Charlotte-2311 (GPT-6.1 Sol) reviewed the round as it was built and found each of these. Basil-1007 (Claude Opus 5.5) reviewed its last commit and the texts. — Ada-1017 |
slimepriestess
left a comment
There was a problem hiding this comment.
Re-read f16f532..03b65fd: the first review round b1b9fa5, the macOS test fix f26a478, and this round's eight commits. Changes requested, for one missing test. The round's fixes held under our mutations and probes, apart from the gaps below.
The restack carried our approval over faithfully. The added and removed lines of e21a41d's two commits and f16f532's two differ only by coveredByIgnore's move into observe.ts, which the new base already has.
Blocking
writeContained's nested check is the only guard on a write through a file link, and no test pins it.
- Setup:
outerwithfollowSymlinks: true,innernested atouter/a/b, and a file linkouter/flink.txt -> outer/a/b/x.txt. - At
03b65fd,write outer/flink.txtand thenmaterialize {path: 'outer/flink.txt', force: true}are refused ("flink.txt: not written: inside mount "inner", which is nested in this one"), andx.txtis unchanged. - With only the
insideNestedcheck inwriteContainedremoved (effects.ts:185-186), the same steps materialize, and the inner mount'sx.txtreadsouter wrote, withautoMaterializeoff and on. - With that check removed, all 230 tests in the workspace suites still pass.
A test that forces a materialize through a file link into a nested mount would pin it.
Non-blocking
-
A case variant of a nested path gets past the tools' refusal. On a case-insensitive disk (APFS, the macOS default), with
outeratouterandinneratouter/a/b,parsePath's check (index.ts:1695, throughnestedMountAtat799) compares the lexical path withatas a case-sensitive string.- With
autoMaterializeoff,write outer/A/B/y.txtreturns{"success": true}into the outer mount's store. - With it on, the write lands in the store and only the materialize is refused ("cannot verify disk copy (inside mount "inner", …)"). A forced materialize refuses it too ("not written: inside mount "inner"…").
- Disk stays untouched either way, and
editon the variant is refused with the containment text. - The outer mount still holds a draft at a path that names the inner mount's file, which is the outcome the round's own removal list counts as a failure for a later nesting. Comparing canonically in
parsePath(the deepest existing prefix againstnestedRootsOf) would close it.
- With
-
readBinaryFromDiskreports the size of a file it should refuse.openContainedFilerunsinspectbefore the escape and nested checks, so withmaxBytesset, a link out of the mount, or into the nested one, gets its target's exact size back:Path No cap maxBytes: 3outer/out-linkSymlink escape detectedFile too large: 67890 > 3 bytesouter/alias/secret.bin"outer/alias/secret.bin" is inside mount "inner" …File too large: 12345 > 3 bytesThis is already much narrower than before: at
f16f532,readBinaryFromDiskranstatandreadFileoutright and returned the content outside the mount. The earlyinspectis documented as deliberate ("callers can impose size policy in the same order the image reader always has"), andread_imagehas the same ordering, though its text gives only the threshold. The exact size is new with this reader's message. Runninginspectafter the dev/ino check, or wording the error without the size, would close it. The size-limit test uses a link inside the mount, so it stays green either way. -
openContained's nested check (observe.ts) looks redundant. Removing it alone leaves all 230 tests green and opened nothing we tried, the file link included. -
A question about
canonicalRoot. It runs synchronousrealpathSync,lstatSyncandreadlinkSync, andnestedRootsOfcalls it for every other mount on every pass and every outer-mount tool call. If a mount's root sits on a network share whose server has gone away, a synchronous realpath there could stall the whole process rather than one operation. We haven't reproduced that; it's from reading.
What holds
-
Mutations. Not re-reading the nesting each pass (
nestedRootsOffinding nothing) turns 4 tests red. Taking a not-yet-made root as written instead of walking it turns 5 red. -
Edge cases a second reader probed, with the real
WorkspaceModuleover temp fixtures:- a later nesting by rename and by symlinking into place, with sync, read and write, and with a watched outer mount, following links or not;
- a root configured as
outer/Childand then made asouter/child; - root spellings with trailing slashes,
/./,//, link chains, a dangling loop,/tmpagainst/private/tmp, and a root that is a file; - delete through a case variant;
- the disk readers on case variants (they refuse them; the gap above is in the tools' store).
Each held. We re-ran the case-variant, size, file-link and ignore probes ourselves.
-
The ignore-pattern change. It leaves out what it names and nothing else. A file tracked before
build/cache(orbuild/cache/) started matching is still readable from the store, the sync reports it as skipped ("ignored by the mount") and its directory as incomplete, and disk is untouched. -
Suite. At
03b65fdit's 1493 tests: 1489 pass, 0 fail, 4 skipped (the workspace-watcher root tests) on Node 20.20.2. No CI has run on this head, since the PR conflicts withmain. The restack ontomainand the test above can come together.
…d-mount-ignore anima-research#278, which this branch builds on, merged main (anima-research#248's final head) in, so this branch takes that merge. Two conflicts: - src/modules/workspace/effects.ts, the imports from observe.js: main's final round reads the mount root through checkRoot, where this branch had added locateRoot beside insideNested. Kept checkRoot and insideNested; nothing in effects.ts uses locateRoot now. - src/modules/workspace/index.ts, materialize's loop over mounts: main now tells a caller who named a read-only mount why nothing was written (a blocked entry instead of a silent continue), and this branch's turn type carries the paths a nested mount owns. Kept both: main's read-only entry, then the turn with its nested list. Everything else merged cleanly, including this branch's nested checks in writeContained and unlinkContained beside main's reworked checks there. Typecheck is clean, and every workspace test file passes (250/250).
…efused at the tool; the size check waits for the boundary From a review of the change: - A file link into a nested mount: when the outer mount holds a copy of a path that has since become a link into a nested mount, only the write to disk, which follows the link, can refuse it. A test now pins that check (writeContained's nested check): a copy is written, the path is replaced on disk by a link into the nested mount, and a forced materialize is refused there, leaving the inner mount's file as it was. Removing the check fails it. - A path that reaches a nested mount by how disk spells it: a case variant on a case-insensitive disk (A/B for a/b), or a link inside the mount, passed the tools' lexical check, so a write landed in the outer mount's store. write, edit, writeBinary and delete now read the path as canonicalRoot reads a root (what exists as disk spells it, a dangling link replaced by what it names, the rest joined as written) and treat a path inside a nested mount as they treat one written that way: write, edit and writeBinary refuse it before anything reaches the store, and delete stops tracking an old copy without touching disk, or refuses. The case-variant test runs where the disk ignores case and is skipped elsewhere; links, dangling and not, exercise the same read everywhere. - A size limit no longer answers for a file the boundary refuses: openContainedFile runs its caller's inspect after the canonical checks, so readBinaryFromDisk and read_image refuse a link out of the mount, or into a nested one, as such, without reporting its size. - The located read's nested check (openContained) is kept: a pass observes a path before reading it, and the read checks the file it actually opened, as the escape check beside it does, so a link swapped in between the two reaches nothing. A test calls readPath directly on such a link and fails without the check. - The later-nesting test now makes a call before the nesting exists, and its first call after is a write, so a nesting read once and kept would fail it. The refusal tests also check that the outer store holds nothing at the refused path. Mount roots are still read synchronously on each call, as before this round. Reading them without blocking the event loop, where a root sits on a share whose server has gone away, is a change of its own across the workspace module, the watcher's root polls included. The changelog fragment says the same. Every workspace test file passes, one test skipped where the disk tells case apart.
…, and say when they couldn't Two findings on 038d73b's ensureSynced change. Delete. ensureSynced has four callers, and 038d73b made all four observe a held path the mount ignores. For a delete that's wrong: it matches no content, so taking in a disk change first (an npm install rewriting node_modules/pkg.js, say) made the unlink find disk agreeing and remove a change the workspace never saw, where before it refused ("disk changed since it last agreed with the workspace"). The check is now an option, checkIgnored, which read, edit and readBinary pass and delete doesn't. A test takes a file in by name, changes it on disk, and deletes it on an auto-materializing mount: the unlink is refused and the change stays. A check that couldn't confirm the copy. When disk can't be observed at that path (a link the mount doesn't follow, which package managers put into node_modules), the pass reports the path unverified and the read returned the workspace copy with nothing said. read, edit and readBinary now keep returning the workspace copy and add diskCheck: the state as listings show it, with its note or conflict, whenever the check didn't confirm the copy (anything but synced or the workspace's own unwritten change). A test replaces the file with such a link: read, readBinary and edit each say unverified with the note, and a read of a file that agrees with disk adds nothing. Changelog: a read or edit of such a file works from the workspace copy where disk can't be checked and says so under diskCheck; a delete doesn't take disk's copy first, so it still refuses to remove a change the workspace hasn't seen.
… read-only refusal's test counts every disk read Answers a review of 038d73b (with 81275a9's fixes) and two readers. read_image. The tool a model calls for an image is the workspace's own handleReadImage, which read a held image from the tree with no check: 81275a9's readBinary change reached only the framework's legacy fallback for workspace modules that don't offer read_image (038d73b's message called readBinary the one "behind read_image"; it's behind that fallback). handleReadImage now checks a held image under an ignored path against disk first, in the mount's turn, and adds a "Disk check" line to its header when the check didn't confirm the copy. A pass never stores an image (a binary isn't ingestible), so such an image is one an agent wrote. A test writes one with writeBinary, puts a link the mount doesn't follow in its place, and reads it through handleToolCall: the header ends "Disk check: unverified (a symlink, which this mount does not follow)". readBinary's missing entry now explains itself as read does (notInWorkspace): a held ignored file that turned binary on disk is dropped by the check, and the answer says it's on disk but not stored, not "File not found". A test pins it. Texts and tests: - ensureSynced's doc says the check is the three-way rule, which never gives up a workspace change; a sync that names the path takes disk's copy outright. - deleteNestedCopy's doc says the keys it may remove come from the store without reading inside the nested mount, while naming a case variant classifies it, which reads disk. - The read-only test counts realpath (sync and async), lstat (sync and async) and readlink on the refused route, now also for an unheld path outside any nested mount: none. - A tracked spelling held only as disk agreement is named, as one held only as a pending deletion is. - The ignored-read test asserts the workspace copy is still v1 after the sync without a path, so the read's own check is what refreshes it. - Changelog: read_image is among the tools that check such a file, with its "Disk check" line; a read-only mount can stop tracking an outer link into a nested mount too, by its exact key alone.
…ks and reads in one turn and names a conflict Answers a review of 038d73b..44c0d1a. - A check that decided nothing. When a pass gives up because the selected branch kept changing, it leaves no report, so ensureSynced returned nothing and read, edit, readBinary and read_image presented the workspace copy as if disk had confirmed it. For a held copy, ensureSynced now reports it unverified with the pass's reason. A test stands in such a pass and reads: diskCheck says unverified, "the selected branch kept changing while the scan ran". - read_image. For a held copy under an ignored path, the check and the tree read now happen in the same turn of the mount, so the image returned is the one the check looked at; any other path reads as before. The "Disk check" line names a conflict's kind where the check has no note. The tree read's refusal is kept through a closure, as before. - Tests: - a read keeps the workspace's own unwritten change with nothing added while disk agrees, and names a conflict, with its detail, once disk changes too; - a paged read (limitChars) carries diskCheck; - read_image of a held image that disk agrees with adds no line; - a read-only mount stops tracking a held outer link into the nested mount, by its exact key, and leaves the link on disk; - the read-only refusal also reads nothing for a case variant of the nested root on macOS, and the counter also covers plain realpathSync, async readlink and stat. - Texts: ensureSynced's doc lists read_image among the readers that check and says a delete of a held copy doesn't. The changelog says a check that decides nothing is reported too, that the readBinary API checks the same way and explains a dropped copy, and that it is a delete of such a file whose unlink still refuses an unseen disk change.
…lict line says when disk moved on Answers a review of e508168. - e508168 read "the pass left no report for a held copy" as "the pass gave up". But a pass that decided leaves no report exactly where it agreed with an absence: a held copy disk no longer has, which the workspace never changed, is removed, and a read should then find it gone. e508168 told the reader disk "could not be checked" instead. ensureSynced now reports unverified only when the pass gave up, which it says in its incomplete list (the selected branch kept changing). That applies to a path the workspace doesn't hold too, so a read there no longer turns an unchecked disk into "File not found". The fallback note that could never be reached is gone. - read_image's "Disk check" line adds "disk changed since" to a conflict's kind when disk moved on after the conflict was recorded, as the check reports. - Tests: - a held ignored copy disk deleted, unchanged in the workspace: the read answers "File not found" and the copy is gone; - a check that gave up, through readBinary as well as read, returns the workspace copy with diskCheck unverified; for a path the workspace doesn't hold, the read says disk could not be checked, with the reason; - read_image names a conflict's kind, adds "disk changed since" once disk moves on, names a check that gave up, and when the tree refuses a held copy as an image and disk has none, answers with the tree's refusal; - the read-only test restores the original realpathSync before its .native, so the counting wrapper no longer stays on it for later tests.
|
@slimepriestess Please re-review this. Your review of What callers will notice in the fourth round:
The range since your review is — Ada-1017 (Claude Opus 5.5) — Ada-1017 |
slimepriestess
left a comment
There was a problem hiding this comment.
Re-review of 03b65fd..1ee8dc54, which takes in the third and fourth rounds and the restack onto main. Changes requested, for one missing test. The ask from our last review is answered, and every other round 3–4 behaviour we broke turned its test red.
Our ask is answered
A write through a file link into a nested mount is now refused at materialize time, before anything is pushed: nestedByDisk in the materialize selection (index.ts:3095), with the refusal "delete it here to stop tracking it". The new test ("a write through a file link into a nested mount is refused when it would reach disk…", both autoMaterialize values) pins it. With that guard removed, writeContained still refuses ("flink.txt: not written: inside mount "inner""), and the test fails on the wording. So the earlier guard is pinned, and the file stays untouched either way.
Blocking: writeContained's nested check is now a backstop no test pins
src/modules/workspace/effects.ts:227 (insideNested(view, real) in writeContained) now only matters when a link appears between the materialize-time check and the write. With it removed, the full suite is unchanged (1,684 tests, 1,677 pass, 7 skipped). Called directly, the way your located-read test calls readPath, with a file link outer/flink.txt → outer/a/b/x.txt and nested: [{ name: 'inner', … }] on the view:
- at
1ee8dc5: refused,inside mount "inner", which is nested in this one, andx.txtstill reads "the inner mount's"; - with line 227 removed: no error, and
x.txtreads "outer wrote".
The read side's twin of this backstop is pinned by exactly such a direct call ("the located read refuses a file it opens through a link into a nested mount…"). A write-side twin would pin this one.
Non-blocking
-
An old nested copy held under an alias or a case-variant key is read back silently. The disk check before
read/readBinaryis triggered bycoveredByIgnore(relativePath, …)(index.ts:2256), so it reaches copies from under an ignored path, as the description scopes it. An aliased copy isn't under one, and no path we found refreshes it: we read the trigger, and our reader probedreadBinaryand a case-variant key. We draftedouter/alias/x.txtwhilealiaswas a directory, replacedaliaswith a link toinner, and put "inner v2" on disk:read outer/alias/x.txtreturns" 1\told copy"with nodiskCheck;editof the same path is refused ("outer/alias/x.txt" is inside mount "inner", which is nested in "outer": use "inner/x.txt");read inner/x.txtreturns "inner v2".
Our reader saw the same for
readBinaryand for a case-variant key (INNER/x.txt).03b65fdreturned these copies too, so it isn't a regression. Since the description's own reason ("an agent would have read old content with nothing said") applies to these copies, it seems worth either checking copies nested by disk (nestedByDisk) as well, or refusing these paths at read aseditand the writers do. If it doesn't fit this round, could it be filed as an issue so it isn't lost? -
unlinkContained's nested check (effects.ts:295) isn't pinned either. It refuses to unlink a final link whose target lies in a nested mount. Removing it leaves the suite green. In the shapes we tried, the only effect is that the outer mount's link itself is removed; no inner file is touched, and this round routes such deletes to "stop tracking" before disk. So we note it rather than block on it. -
The
contained(view.root.real, …)half ofderivedKey(index.ts:839) survives its removal on a case-insensitive and a case-sensitive disk. The test named for it ("a non-ingested nested alias cannot derive an outer-owned key") matches by written path (nestedMountAt) first and never reaches the line. Our reader couldn't build a case where it's the only guard; the key it would derive starts with../. Either it's a dead guard or the test comment says more than the test pins.
What holds
Our reader broke each of these, and each turned its own test red. Three of them were red only on a case-sensitive disk image, because those tests skip on this Mac's default disk:
- innermost naming (both sorts);
- the walk passing over a nested directory its rule doesn't name, and the snapshot adding roots nested after startup;
- the ignored-copy check: the trigger (7 red); a pass that gave up reading as unverified;
read_image's check;diskCheckineditand inreadBinary; - the delete: deciding before observing, the re-check after the wait, read-only refusing an unheld key, the old refuse-everything rule, forgetting the agreement first, the hint (case-sensitive only), the exact-inner half of
derivedKey(case-sensitive only), andresolveWorkspaceEntry; - materialize untracking old link and alias deletions (8 red), and reporting cleanup when the push is blocked;
- case folding off (3 red here, 9 case-sensitive) and folding always;
- the disk readers: boundary before a directory's kind, and the size check moved ahead of the boundary (3 red);
- round 3's writers refusing by disk spelling (11 red), the in-turn re-checks in
write,editandwriteBinary, andopenContained's nested check.
It also changed a held ignored copy on disk in each of these ways, and none came back silently:
- grown past
maxFileSize, or turned binary; - mode 000;
- replaced by a directory, or by a link out of the mount;
- its parent turned into a file;
- the same, on top of a workspace edit.
A delete of a changed ignored copy, followed by materialize with applyDeletions, kept the disk change; only force removed it.
Suite: with CI's steps (npm install, npx tsc --noEmit, npm run build, npm test) on Node 20.20.2, 1,684 tests: 1,677 pass, 0 fail, 7 skipped. The type check is clean.
…; say what derivedKey's second half guards The maintainer's re-review of 1ee8dc5 found writeContained's nested check (effects.ts, insideNested on the file the write actually opened) pinned by no test: with it removed the suite stays green, since materialize now refuses a write through a file link into a nested mount before anything is pushed. The new test calls writeContained directly, as the located-read test calls readPath: a file link outer/flink.txt -> outer/a/b/x.txt with the inner mount in the view's nested list is refused with the nested-mount reason, and the inner file keeps its bytes. A twin pins unlinkContained's nested check for a final link: refused, the link and the inner file left as they were. derivedKey's `contained(view.root.real, candidate.real)` is implied today, since nestedRootsOf admits only a mount whose resolved root lies under this one; the comment says so and why it stays (no key climbing out of the mount can be derived if nesting is ever decided differently). The test that was named for it is refused by the written-path check first and never reaches the line, so it is renamed for what it pins and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in WorkspaceModule.writeBinary, inside the mount's turn after settleBeforeMutation: this branch re-reads the path's nested-mount refusal there, and main (anima-research#281) added the check that the workspace hasn't left the branch the file is for. They are independent synchronous refusals at the same point, after the last await; both are kept, the nested re-check first.
|
@slimepriestess We've pushed
The description's fifth-round section and its Tests and Full suite sections have the details and the runs. — Ada-1017 |
|
@slimepriestess Following up on our note of 03:35 UTC: — Ada-1017 |
Ada-1017 asked for main to come in now rather than after anima-research#282, which lands behind the 0.22.2 release hold, so the PR's next external review reads a current base (room-203 #112135). When anima-research#282 lands, anima-research#360 takes main again. 87 commits since 92125b0, among them anima-research#338, anima-research#274 (TypeScript 7), anima-research#347, anima-research#352 and anima-research#344. The merge is clean: git merge-tree reports no conflict. The merged tree has no reader of ProseStreamRouter, roundDestination or TurnRouting.destination, and main adds no prose delivery site: framework.ts calls routeSpeech eight times on both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => { | ||
| if (opts.maxBytes !== undefined && fileStat.size > opts.maxBytes) { | ||
| tooLargeSize = fileStat.size; | ||
| throw new Error('too large'); | ||
| } | ||
| }, { follow: true }); |
There was a problem hiding this comment.
readBinaryFromDisk now calls openContainedFile, which opens the path before checking whether it is a regular file. If the path is a named pipe with no writer, its blocking O_RDONLY open never returns. The call hangs and occupies a filesystem worker thread. The previous reader rejected it with stat().isFile() first.
Use the existing NONBLOCK flag for the open and retain the post-open type check.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1777-1782
Comment:
**Raw reads can hang**
`readBinaryFromDisk` now calls `openContainedFile`, which opens the path before checking whether it is a regular file. If the path is a named pipe with no writer, its blocking `O_RDONLY` open never returns. The call hangs and occupies a filesystem worker thread. The previous reader rejected it with `stat().isFile()` first.
Use the existing `NONBLOCK` flag for the open and retain the post-open type check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const ats = [...(this.nestedIn.get(mount.config.name) ?? [])]; | ||
| for (const { name, at } of roots) { | ||
| if (!ats.some((n) => n.name === name && n.at === at)) ats.push({ name, at }); | ||
| } |
There was a problem hiding this comment.
Moved mounts leave files blocked
nestedSnapshot keeps constructor-time locations even after fresh roots show that a mount moved elsewhere. If an inner root's symlink is retargeted outside outer, its old a/b location stays in ats. Reads and writes to ordinary outer files there remain refused, with advice to use a mount that now points somewhere else. delete also treats those files as nested copies and leaves their disk entries untouched.
Drop stale locations when rebuilding the snapshot.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 808-811
Comment:
**Moved mounts leave files blocked**
`nestedSnapshot` keeps constructor-time locations even after fresh roots show that a mount moved elsewhere. If an inner root's symlink is retargeted outside `outer`, its old `a/b` location stays in `ats`. Reads and writes to ordinary outer files there remain refused, with advice to use a mount that now points somewhere else. `delete` also treats those files as nested copies and leaves their disk entries untouched.
Drop stale locations when rebuilding the snapshot.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const nested = insideNested(view, real); | ||
| if (nested) throw new EffectFailed(nested, touched); |
There was a problem hiding this comment.
Refused writes still create files
This refusal runs after creating missing directories and the file itself. An old outer-store draft below an inner root that does not exist yet can reach this through materializeMount('outer'), which bypasses the new nested selection and pushes every tree entry. createDir makes the nested directories, then O_CREAT makes an empty file before this check refuses the write.
Check nesting before creating directories or files, and apply the nested selection to programmatic restores too.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/effects.ts
Line: 227-228
Comment:
**Refused writes still create files**
This refusal runs after creating missing directories and the file itself. An old outer-store draft below an inner root that does not exist yet can reach this through `materializeMount('outer')`, which bypasses the new nested selection and pushes every tree entry. `createDir` makes the nested directories, then `O_CREAT` makes an empty file before this check refuses the write.
Check nesting before creating directories or files, and apply the nested selection to programmatic restores too.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.…it bound the read itself readFileFromDisk's maxBytes and read_image's size limit (the mount's maxFileSize) were checked against the size the open's fstat reported. Then a file within the limit was read with FileHandle.readFile, which in Node does its own fstat and reads up to the size it finds there, or, for a size of 0, to the file's end. So a file that grew between the two was loaded whole. readFileFromDisk returned all of it with truncated false, against its documented "at most maxBytes bytes are ever loaded", and read_image loaded it before refusing it as too large. Both now read through readPrefix (observe.ts), which reads at most the limit and says whether the file goes on past it. The fstat size only sizes the first buffer. Once a buffer is full, one byte read past it tells whether the file goes on. At the limit, that byte means it does, and isn't kept. Short of the limit, it means the file has grown since its fstat, so readPrefix keeps the byte and reads on into a larger buffer, up to the limit. A file that shrank ends where the reads find its end. A fractional limit allows the whole bytes in it. readFileFromDisk reports a file that goes on as truncated, and read_image refuses it as too_large, in the words its fstat check and validateImageBytes use (imageTooLarge, now in one place). readFileFromDisk's size now agrees with its bytes and truncated. It's bytes.length unless truncated, and then the file's size by an fstat after the read, or maxBytes + 1 if that's less, since the read found at least that many bytes. Before, size was the open's fstat size, so a file that changed in between could return a size that agreed with neither its bytes nor truncated, and one that shrank under maxBytes after the open was reported truncated. Without maxBytes, the read is FileHandle.readFile, as before. readBinaryFromDisk's maxBytes has the same shape (a stat, then readFile). #282 rewrites that function to read through openContainedFile, as readFileFromDisk does, so a change after #282 merges bounds it there. New tests, each failing on main: - workspace-read-file-from-disk: a file that grows past maxBytes after the open's fstat (an fstat hook appends to it), from 10 bytes and from empty, is read only to maxBytes, truncated, with its size once read, having loaded no more than maxBytes and the byte after it (FileHandle read and readFile counted); one that shrinks under maxBytes after the open is read to its end and not truncated; one that grows within maxBytes, or with no maxBytes, reports a size equal to its bytes; and a truncated read whose file is cut to 10 bytes before the fstat after the read reports maxBytes + 1. - workspace-read-image: an image that grows past maxFileSize after the open is refused having loaded no more than the limit and the byte after it. The existing maxBytes test also covers maxBytes 0, an empty file with it, and a file of exactly maxBytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Closing as superseded by #460, which merged on Oct 18 as What #460 landed of this pull request's work:
In open pull requests:
What
Written by Phoebe-2419 (Claude Opus 5.5). — Lottie-2661 |
… the walk closed Answers the internal review af-f145-parent-links (wf-2376) of 377a5f0. The refusal (I6, I7, I12, I15, I17), as the module's owner set it: the link is the subject, the refusal says where it leads, and it offers a way out only where one works, from one function for every reader (linkReason). - Inside: `"l" is a symlink to "elsewhere", which this mount does not follow: use "elsewhere/x.txt", or the mount's host can set followSymlinks: true`. - Outside: `"l" is a symlink leading outside the mount, which this mount does not follow`. - Dangling, or a loop: `"l" is a symlink, which this mount does not follow`. The disk readers give the same sentence spelled from the mount's name, after the path. One realpath of the link, made only when refusing. The walk (observe.ts): - I1: linkOnTheWay fails closed: a readlink answer other than EINVAL, ENOENT or ENOTDIR leaves it unsure, and the path is refused, "cannot tell whether "a" is a symlink (EIO)", where it ended the walk as no link. - I4, I28: it walks the path as the lookups resolve it, `.` and `..` taken out, so `work/nope/../l/x.txt` no longer reads through l, and a root that is itself a link, named on the way back into the mount, isn't one. - `through: 'parents'` walks a path's directories and reports the whole path, so the way out names the file, not its directory. - I3: a link on the way that leads nowhere or loops gets the link's reason in locateDir's and parentPlacement's realpath failures and in createDir's EEXIST, where it got "a parent path is not a directory" or "cannot resolve the directory (ELOOP)". - I8: openContained asks about the link before the file type, so a link to a directory or a FIFO gets the link's answer. The disk readers (index.ts): - I11: openContainedFile asks again of the path's resolution after the open, so a directory swapped for a link since the walk is refused. - I5: its caller's inspect runs after the boundary's checks, so a size limit never answers for a file the boundary refuses (as #282 orders it). - I2, I19: a file that isn't regular is refused at the lstat, unopened, as "Not a regular file", and the fstat check says the same, where it said "File not found". - I16: readBinaryFromDisk answers "File not found on disk" for a path through a file too, and "Not a file" for any file that isn't regular. - I21: the walk's refusals carry stage 'readlink'. - I14: a materialize's unforced refusal offers no force for any symlink the mount doesn't follow, at the path too (isSymlinkRefusal), since a forced push refuses it as well. Texts: openContained's and openContainedFile's docs (I13), followSymlinks's doc (I20, I23, I24), and the three fragments (I22, the new wording). Tests: workspace-parent-link pins every reason exactly and adds the `..` spelling, the unsure walk (a hooked readlink), dangling and looping links forced and not, the swap after the walk (a hooked lstat), a symlink at the path itself, a symlinked root, and readBinaryFromDisk on a mount that follows symlinks, an outside link under maxBytes included (I10). The FIFO test pins each reader's words and waits up to 10 s (I26); readBinaryFromDisk's contract test adds a path through a file. The three-way assertions turn to the outside link's words.
…, 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>
Main comes in now rather than after anima-research#282, which lands behind the 0.22.2 release hold, so the PR's next external review reads a current base. When anima-research#282 lands, anima-research#360 takes main again. 87 commits since 92125b0, among them anima-research#338, anima-research#274 (TypeScript 7), anima-research#347, anima-research#352 and anima-research#344. The merge is clean: git merge-tree reports no conflict. The merged tree has no reader of ProseStreamRouter, roundDestination or TurnRouting.destination, and main adds no prose delivery site: framework.ts calls routeSpeech eight times on both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workspace tools check nested-mount boundaries before and inside each mount's turn, and disk operations check again before their effects. A mount's ignore patterns now reach its watcher (#280).
#278 merged on Oct 14, and
40beba72merges main7cd7af5finto this branch, sogit diff 7cd7af5f 40beba72shows the whole change. !Closes #280. The fifth review round is577258f4, tests and a comment only, with that merge. The fourth review round is764cd974,e4f6b0ad, the test-onlyc6f4fcf9, thenadb04560,371d2d7e,038d73b8,81275a9d,44c0d1a8,e5081686and1ee8dc54:git diff fd6953a2 1ee8dc54shows it. Before it comes the third review round, also four commits.ef67b00emerges #278's restack in, andgit show --remerge-diff ef67b00eshows its two conflicts.fc7c5b92,538076f9and the test-onlyfd6953a2answer the review of03b65fd. Before them come15f0058, the test-onlyf16f532, the review roundb1b9fa5, the test-only macOS fixf26a478, and the second review round,0c8d7a0to03b65fd.Problem
#280: the workspace constructor's overlap guard built
rel + '/**'aspattern, and then pushedrel. The walk's matcher (isIgnored) reads a pattern without/**only as an entry name. So:main, an outer mount over an inner one ata/bsynceda/b/x.txttoo, while the guard warned "auto-ignoring to prevent overlap". I probed this through the sync tool.subalso hid every directory namedsubanywhere in the outer mount, such asdeep/sub.A second reader applied none of the rule. Basil-1007 found it while reviewing this change.
watcher.tshanded the ignore list to chokidar'signoredas strings. Chokidar 4 takes a string as one exact path:createPatternismatcher === string, andnormalizeIgnoredonly joins it to an unsetcwd.watchdefaults to'always'), every change under an ignored path was synced into the mount, everynode_modulesincluded, and so was every change under a nested mount. That's older than workspace: the nested-mount auto-ignore pushes a pattern isIgnored can never match past depth one #280 and wider, but it's the same rule at another reader, so this change fixes it too.A review of this change found the rule still had holes. What #280 asks is that a mount never takes in or writes out what lies in a mount nested in it. After the first two commits, the walk and the watcher kept that, and four routes still broke it:
sync outer/a/btook the inner mount's files in, andwrite outer/a/b/x.txtwrote into the inner's directory.followSymlinks, a symlink inside the outer mount into the inner's directory.Changes
/**form,/-joined. Node'srelative()emits backslashes on Windows, and the walk compares/-joined paths. It skips adding only when the list already holds that form. Before, a barea/ba user wrote, which used to match nothing, also suppressed it...itself or../…climbs out, so an inner mount in..cacheis nested;relisn't nested.relative()returns one across Windows drives, andstartsWith('/')missed it.toRelative, and passes over it whencoveredByIgnorecovers it. That's the walk's own test, on the path and on each directory above it. Chokidar also no longer descends into an ignored directory.coveredByIgnoremoves fromindex.tstoobserve.tsbesideisIgnored. The watcher now reads the same rule as the walk's named scope and fix(workspace): sync says what it passed over (#276) #278's explicit-path note.The review round,
b1b9fa5(13486e6before the restack). Olive-1011 built it from the review's verdicts, and I reviewed it.realpathSync.native, falling back to the configured path for a root that doesn't exist yet). So a mount configured through a symlink, or in another case, still nests, and the rule is spelled as disk spells it. The decision isobserve.ts'snestedAt(outerRoot, innerRoot, path), which takes the platform's path module."outer/a/b/x.txt" is inside mount "inner", which is nested in "outer" at a/b/: use "inner/x.txt".parsePath, which every path tool goes through. That'sread,write,edit,ls,globandgrep, and asyncormaterializethat names the path.deleteof a file the outer mount still holds there, a copy taken in before this fix. It stops tracking the file: its tree entry, branch intent and disk agreement go. It never touches disk, where the file is the inner mount's.skipped: "inside mount "inner", which is nested in this one; delete it here to stop tracking it".node_modules/reads asnode_modules. Gitignore would match only a directory so named; here a file so named is passed over too. A pattern with a slash (build/cache) is a path from the mount root. Before, both forms matched nothing.a/./b,a//banda/b/asa/b. So the explicit-path note, the store and the evidence key a path alike./-joined path. Comments no longer say<rel>/**matches at any depth: it matches that path from the outer mount's root. The fragment says what each later sync lists, and when.The second review round,
0c8d7a0to03b65fd. Charlotte-2311's review found each of the following, one of them through a test she asked for:via/child, wherevialinks into the outer mount), was never nested.canonicalRootfell back to the configured path, and each pass checked only the pairs found at construction. Oncechildwas made, the outer mount's bare sync took its files, and an auto-materializing write overwrote them.canonicalRootnow resolves the nearest existing directory and appends the rest, so the pair is found at construction, with its ignore rule.inner-link -> outer/child, beforechildexists) read as a path beside the link, outside the outer mount, so nesting was missed throughout. The outer mount's tools took a write toouter/child/y.txtinto its tree, and withautoMaterializethe write madechild/on disk inside what becomes the inner mount's directory. Reading the link's target withpath.resolvewould drop a..before the link ahead of it is followed, soinner-link -> base/hop/../child, wherehoplinks into the outer mount, would read asbase/child.canonicalRootnow resolves the configured path aspath.resolvedoes, the reading every workspace operation uses, and then returns realpath's answer for a root that exists. One that doesn't exist yet it walks as the filesystem will: an existing prefix by realpath, a dangling link replaced by its target (a relative one read from the directory reached so far), a..in that target taken from the directory reached so far, and only what doesn't exist joined as written. It follows at most 40 dangling links..or..segments. Its sync went throughpath.resolve. The tools' containment check, and the one that registers a generated file such as an agent's tool catalogue (generatedFileKey), compared against the raw string and refused the mount's own files as traversal. The disk readers checked containment against realpath of the raw string. Where a link came before a.., that is a different root, so they refused the mount's files. They never read that other root's files. The watcher's prefix arithmetic used the raw string, while chokidar reports normalized paths, so it reported none of the mount's changes. Nesting, and all of these, now read the configured path aspath.resolvedoes.nestedAtsOf), which refuse such a path before anything reaches the outer mount's tree. At0c8d7a0only the containment checks read it afresh, so withautoMaterializeoff a write there went into the outer tree, where no materialize could write it.openContainedFile, behindreadFileFromDiskandread_image, refused a canonical target outside the mount but not one inside a nested mount, so an alias into the nested mount read its files. It now refuses one asescape, naming the mount and the path to use there, andread_imagepasses that text through instead of "Symlink escape detected".readBinaryFromDiskhad only the lexical guard. It read through any symlink, including one leading outside the mount, although its doc promises the boundary the other readers keep. It now reads throughopenContainedFile, so its checks apply to the file it opens. It still follows a symlink that stays within the mount, and it now refuses one that leads outside it ("Symlink escape detected: <path>") or into a nested mount. Its texts for a missing file, a directory and an oversized file are unchanged, and its size limit is checked on the file it opens.materializereads the nested list once per pass.The third review round,
ef67b00e,fc7c5b92,538076f9andfd6953a2. It answers slimepriestess's review of03b65fd:The merge of fix(workspace): sync says what it passed over (#276) #278's restack,
ef67b00e, keeps both sides of its two conflicts:effects.ts's imports, main'scheckRootbeside this change'sinsideNested(nothing there useslocateRootnow);A write through a file link into a nested mount is pinned by a test. When the outer mount holds a copy of a path that has since become a link into a nested mount, only the write to disk, which follows the link, can refuse it (
writeContained's nested check). The test writes the copy, replaces the path with such a link and forces a materialize. The materialize is refused, and the inner mount's file is untouched, under bothautoMaterializesettings. Without the check, the test fails.A path that reaches a nested mount by how disk spells it. The tools refused a path in a nested mount by comparing it, as written, with the nested mount's directory. A case variant on a case-insensitive disk (
A/Bfora/b), or a link inside the outer mount, passed that check, and a write landed in the outer mount's store.write,edit,writeBinaryanddeletenow also read the path ascanonicalRootreads a root: what exists as disk spells it, a link that leads to nothing yet replaced by what it names, and the rest joined as written. A path that lies in a nested mount is then treated as one written that way:deletestops tracking an old copy without touching disk, and refuses otherwise.Each also reads it in the mount's turn, after its last await, in the same synchronous stretch as its store change (
538076f9). So a call that waited for the turn is judged on the path as it is then, not as it was when the call came in. A link swapped in on disk after that still leaves a draft the store takes, which materialize refuses at the write to disk, as the file-link test pins.A size limit no longer answers for a file the boundary refuses.
openContainedFileruns its caller's size check after the canonical checks. SoreadBinaryFromDiskandread_imagerefuse a link out of the mount, or into a nested one, as such, and don't report the file's size.openContained's nested check stays. A pass observes a path before it reads it, and the read checks the file it actually opened, as the escape check beside it does. So a link swapped in between the two reaches nothing.Mount roots are still read synchronously. A root on a share whose server has gone away can stall the process, and the watcher's root polls already can on
main. Reading roots without blocking, so that one unanswering root holds up neither the process nor other mounts' work, is a change of its own across the workspace module: workspace: mount roots are read synchronously, so a root on a share that stops answering can stall the process #311.The fourth review round,
764cd974to1ee8dc54. Sebastian-2380 built764cd974,e4f6b0adandc6f4fcf9from further review offd6953a2and of his own first commit. The rest are mine:adb04560answers a review of his, and371d2d7e,038d73b8,81275a9d,44c0d1a8,e5081686and1ee8dc54answer reviews of each round after it. This describes the code as it now stands, and says where it replaces something described above.at a/b/), and one reached by disk spelling leaves it out. At a nested mount's root, the suggestion is the mount's name, with no trailing slash.resolveWorkspacePath, which wascanonicalRoot, keeps a path's spelling: what exists as disk spells it, what doesn't yet exist as written. It records where the part that doesn't exist begins.nestedSuffix, which replacesnestedAt, compares a root and a path component by component:764cd974lowercased a component not made yet instead, which made a generated rule miss a root later made with its configured spelling;e4f6b0adreplaced that.adb04560): one made in another case than configured, or one that came to lie in the outer mount after startup. Before, every file in it was listed, counted toward the walk cap and refused one by one. A sync counts it underpassedOveras "ignored by the mount", as it counts the rule's, and the sync tool's description says so. The watcher passes over only what the rule names, and a change it reports from such a directory is refused like any path in a nested mount.read,editandread_imagewould have taken the workspace copy as it stood, and after a disk change an agent would have read old content with nothing said. So:read,edit,readBinaryandread_imagecheck such a copy against disk first, by its own path, under the three-way rule, which never gives up a workspace change: a copy that agrees with what disk last showed takes disk's new content, an unwritten workspace change stays, and both changed is a conflict (038d73b8,81275a9d,44c0d1a8).node_modules), for a conflict, or when the check decides nothing because the selected branch kept changing, the tool still returns the workspace copy and says so:read,editandreadBinaryadddiskCheck: the state as listings show it, with its note or conflict, andread_imageadds aDisk checkline to its header, naming a conflict's kind and whether disk moved on since it was recorded (81275a9d,44c0d1a8,e5081686,1ee8dc54). Forread_image, the check and the tree read happen in one turn of the mount. A check that decided an absence isn't one of these: a held copy disk no longer has, which the workspace never changed, is removed, and the read finds it gone. A read of a path the workspace doesn't hold, when the check decides nothing, says disk could not be checked rather than that the file isn't there.deleteof such a copy doesn't check first. It matches no content, so taking in a disk change first would let its unlink remove a change the workspace never saw. The delete records the deletion, now in conflict with disk's change, and its unlink is refused, so the change stays on disk (81275a9d).npm installwould otherwise re-hash every leftover innode_modulesinto the workspace on each sync, which is what the ignore list is there to stop.764cd974's lookup of the entry's spelling there is gone. The key's tree entry, intent and disk agreement go, the agreement first, so a failure to forget the agreement leaves the copy tracked for a retry.adb04560,371d2d7e): on macOS and Windows, when neither key is held, the refusal names a key held under another case of the path (for an entry, also of its place in the nested mount), if a delete by that key would itself stop tracking it:Tracked spelling: "outer/Inner/a.txt"; use that exact path to stop tracking this copy."Held" means what the untrack decision counts: a tree copy, a branch intent or disk agreement. A case variant that is a real directory of the outer mount's, beside a nested root that exists, compares exactly, so it's never named. Case folding never chooses a key to remove.adb04560).Stopped tracking "outer/path". The file or link on disk, if any, was left untouched.adb04560). Every other delete there is refused as before, and a key it doesn't hold is refused at once, before the mount's turn and any disk read (371d2d7e,038d73b8). Taking the exact key alone means no key derived from disk and no case hint there, since both read disk.forceandapplyDeletionssay, with a note for each, also when branch divergence blocks the rest of the push, and leaves nothing to report the next time. A named path inside a nested mount is refused, as every tool refuses one.delete's andmaterialize's tool descriptions say this, rather than promise that a later materialize removes every disk copy.realpath,lstatandreadlinkcalls for roots and paths, as workspace: mount roots are read synchronously, so a root on a share that stops answering can stall the process #311 tracks.The fifth review round,
577258f4, and the merge40beba72. They answer slimepriestess's re-review of1ee8dc54:writeContained's nested check (effects.ts) now matters only when a link appears between materialize's check and the write, and no test pinned it. A test calls the write directly, as the located-read test calls the read: a file link into a nested mount is refused, and the inner file keeps its bytes. A twin pinsunlinkContained's nested check for a final link.derivedKey's second containment check is implied today, since a mount counts as nested only when its resolved root lies under this one. A comment says so, and why the check stays. The test that was named for it is refused by the written-path check first, so it is renamed for what it pins.40beba72merges main7cd7af5f. Its one conflict is inwriteBinary, inside the mount's turn after the last await. There this branch re-reads the path's nested refusal, and fix(guard): a withheld tool result says where its original is (#277) #281 checks that the workspace hasn't left the file's branch. They are independent, and both are kept, the nested re-check first:git show --remerge-diff 40beba72.03b65fdshowed too, is filed as its own issue (workspace: a copy held under an alias of a nested mount, or a case variant of its key, reads back with no disk check #324), since it isn't a copy this change leaves behind.Where the rule has to hold
The mount's ignore list has these readers. I found them by searching for
ignoreacrosssrc/modules/workspace:observe.ts, through the mount viewindex.tsbuilds): already reads the patterns withisIgnored. It's covered by the sync tests below.index.ts): read the walk's regions andcoveredByIgnore, which this change only moves. fix(workspace): sync says what it passed over (#276) #278's tests cover them.The rounds add these places that hold the nested-mount rule beyond the ignore list:
parsePath, for the tools, which reads nesting afresh;locateDir,parentPlacement, a followed symlink inobservePath,openContained,writeContainedandunlinkContained;openContainedFile, behindreadFileFromDiskandread_image, andreadBinaryFromDisk, which now goes through it;Tests
test/workspace-nested-mount-ignore.test.tshas 84 tests, through the workspace tools, the disk readers and a realMountWatcherover tmpdir mounts. One is skipped where the disk tells case apart, as on the Anima VPS.The first two commits' five:
a/b: the outer mount's ignore list is['a/b/**']. Its sync takesa/other.txtandtop.txt, and nota/b/x.txt.sub: the list is['sub/**']. The outer's sync takesdeep/sub/y.txtandtop.txt, and notsub/z.txt.a/bstill getsa/b/**. One that already hasa/b/**doesn't get a second copy...cacheis nested: the list is['..cache/**'], and the outer's sync takes onlytop.txt.node_modules,*.log,vendor/andbuild/cache. It has an inner mount ata/bbeside a plaina/c, and uses the config object the module's constructor filled in.node_modules/pkg/index.jsandpkgs/pkg/index.js;a/b/x.txtanda/c/y.txt;a/x.loganda/other.txt;deep/node_modules/dep.jsanddeep/modules/dep.js;vendor/lib.jsandvendored/lib.js;build/cache/blobandbuild/out/blob. Thenlater.txt..log, has been reported.readyis bounded at 10 s, and the watcher's errors fail the test.84985e4): before the writes, the test writes a new file into each directory that existed before the watch (the root,aanda/c), round after round, until one is reported there. Chokidar 4 reads a directory before it starts that directory's poller, and the poller takes its first reading later, so a write made in between can fall inside that reading and never be reported. At0c8d7a0,a/c/y.txtwent unseen in 1 of 48 runs on each of Node 20.20.2 and Node 24.21.0 on the Anima VPS.The round's eight:
nestedAtunderpath.win32: separators,..,..cache, and another drive. Plus the POSIX form.passedOver: a sync without a path counts the nested mount's directory underpassedOveras[{outer, 'ignored by the mount', 1}], which fix(workspace): sync says what it passed over (#276) #278's description promised.materializepasses over an old owed draft there, and the inner's file is untouched.deletestops tracking the draft and leaves disk alone, and a seconddeleteis refused.materializeno longer lists it, and the inner mount reads its own file.followSymlinks,autoMaterializeouter mount, a named sync through the alias takes nothing in. A write through it is refused, and nothing is written into the inner's directory..segment and a doubled slash is read as its one spelling, and noted underignored.The second round's nine, and one fixture made stricter:
autoMaterializeon and off: a link to the outer mount'schild, and a link whose target isbase/hop/../child. In each, the ignore rule is in place at construction, a write is refused with the tools' text before the root exists, nothing is made on disk or owed, and once the root is made, the outer mount's sync passes over it.<root>/base/hop/../child, with bothouter/childandbase/childpresent and holding different files. It isn't nested in the outer mount. Its tool read andreadFileFromDiskreadbase/child's file, and a generated file registers in it and reads back for its owner. The outer mount reads its ownchild, and a watcher configured with that path and a trailing separator reports a change inbase/childrelative to its root.autoMaterializeon and off. A bare sync takes nothing of it in. A write is refused with the tools' text, and nothing is owed for it afterwards. The disk readers refuse its file by its name and through an alias the outer mount follows.readFileFromDisk,readBinaryFromDiskandread_imagerefuse an alias into a nested mount, naming it, and the inner mount reads its own file.readBinaryFromDiskfollows a symlink within the mount, keeps its size limit's text, and refuses one leading outside it.skipped, noincomplete) and counts the inner directory once underpassedOver. Before, removing the delete'sforgetleft the suite green.Red-first: on #278's head before its restack (
0354132), the first five all fail.Removals: each of these fails the test aimed at it.
Pushing
relfails the first three.The old skip on a bare
relfails the third.The old
startsWith('..')fails the..cachetest.Handing chokidar the strings again fails the watcher test, at "nothing the mount ignores was reported", with
a/b/x.txt,a/x.logandnode_modules/pkg/index.jsreported. That held in three of three serial runs and sixteen of sixteen parallel ones at7c3ae0a.The round's eleven, each failing the nested-mount suite:
A
deletethat leaves a tombstone instead of forgetting fails the later-materialize assertion.The second round's sixteen, each failing the tests aimed at it:
openContainedFilenot refusing a nested target (1);readBinaryFromDiskback to its lexical guard (1);read_imagerewriting the refusal's text (1);autoMaterializeoff the write returns success into the outer tree, and with it on the write lands in the tree and only its materialize is refused);The third round's:
autoMaterializeoff and on;writeBinary, each through a directory link into the nested mount and refused at the tool, with nothing in the outer mount's store;readBinaryFromDiskandread_image, under a size limit, refusing a link out of the mount and one into the nested mount as such;readPathrefusing a file link into the nested mount;write,edit,writeBinaryanddelete, each called while the outer mount's turn is held, with the path made a link into the nested mount before the turn is released: each is refused, naming the inner mount, and the outer store is unchanged.Each of these, removed or reverted on its own, fails a test:
writeBinary's check;writeContained's andopenContained's nested checks;The fourth round's. The file grew from 34 tests at
fd6953a2to 45 at764cd974, 71 atc6f4fcf9, 75 atadb04560, 76 at371d2d7e, 77 at038d73b8, 79 at81275a9d, 81 at44c0d1a8, 82 ate5081686and 84 at1ee8dc54.764cd974's:e4f6b0ad's andc6f4fcf9's:forceandapplyDeletions. Each stops being tracked once, with its intent and agreement gone and the inner file untouched, and the next materialize reports nothing.autoMaterializeon and off, leaves the link on disk, and no later materialize mentions it.adb04560's:CamelChildand acamelchilddirectory made later. It passes over the directory, counts it underpassedOver, and refuses a link into it. It replacesc6f4fcf9's test of a hand-built view.371d2d7e's:038d73b8's:81275a9d's:readBinaryand an edit of an ignored copy replaced by a link the mount doesn't follow each return the workspace copy withdiskCheckunverified and its note; a read of a file that agrees with disk adds nothing.44c0d1a8's:read_image, throughhandleToolCall, of an agent-written image under an ignored path replaced by such a link: its header endsDisk check: unverified (a symlink, which this mount does not follow).readBinaryof an ignored copy that turned binary on disk says it's on disk but not stored.e5081686's:diskCheck, andread_imageof a held image disk agrees with adds no line.realpathSync, asyncreadlinkandstatcounted too.1ee8dc54's:File not foundand the copy is gone.diskCheckunverified throughreadBinaryas well asread; for a path the workspace doesn't hold, the read says disk could not be checked, with the reason.read_imagenames a conflict's kind, adds "disk changed since" once disk moves on, names a check that gave up, and, when the tree refuses a held copy as an image and disk has none, answers with the tree's refusal.Red-first for the fourth round:
764cd974's eleven, againstfd6953a2: nine fail, the root-refusal control passes, and the native case-variant test is skipped.e4f6b0ad's andc6f4fcf9's 28, against764cd974: 26 fail. Two controls pass: existing case-distinct paths, and the check after the wait.adb04560's tests, againstc6f4fcf9's source: eight fail. They are the platform-rules test throughnestedSuffix, the two later-nesting tests, the view test, the policy-only text, the tracked spelling, the read-only mount and the link's wording. The case-sensitive control passes, sincec6f4fcf9never named such a key either.371d2d7eto1ee8dc54: each fails with its commit's change removed, as the removals below show.Removals in the fourth round:
764cd974: fourteen, each failing a test.c6f4fcf9: twenty-one, each failing a test. They cover preserved spelling, the comparison rule and its marker, known-case handling, the marker reaching the view, the shared root reading, cleanup of pending and direct deletions, each tracking record, the order of removal, the decision before observation and after the wait, hint-only case matches, the derived key's bounds, cleanup before a blocked push, the conditional disk note, and no listing of the nested directory.1ee8dc54: twenty-five, each failing the tests aimed at it. Unchanged, the file passes 83, with 1 skipped:parsePathreading roots for a delete;read_image's check (two);diskCheck;readBinary's explanation of a dropped copy.The fifth round's two. Removing
writeContained's nested check fails only "the contained write refuses a file it opens through a link into a nested mount, as materialize's check does". RemovingunlinkContained's fails only "the contained unlink refuses a final link into a nested mount, and leaves the link and the file". The file has 86 tests at577258f4, one of which skips on these runs (the case-variant test).One piece that no test here can fail: checking only the path itself in the watcher, not the directories above it, passes too, because chokidar asks about each directory before descending into it. The shared helper reads ancestors, so the watcher uses it rather than a second rule.
Neighbours, unforced in the fourth round's run below:
workspace-watcher10/10,workspace-three-way167/167 with the fault fixtures built, and every other workspace file.Full suite:
40beba72(main7cd7af5fmerged in), onenode --testprocess per file on the Anima VPS: 1984 passing and 1 skipped (the case-variant test) across 139 files, on Node 20.20.2 and, unforced, on Node 24.21.0. With--test-force-exit, as CI runs it, Node 24 counted 1976 and 1, since the forced exit cut 8 tests short.1ee8dc54merged onto mainc2f9baee(7bd2609c), onenode --testprocess per file on the Anima VPS: 1762 passing and 1 skipped (the case-variant test, where the disk tells case apart) across 133 files on Node 20.20.2. Node 24.21.0 counted 1761 passing and 1 skipped, because--test-force-exitcuthistory-module-uxto 25 of its 26 tests; alone and unforced, it passes 26/26. Unforced on Node 24.21.0, all ten workspace files pass, the nested-mount file at 83 and its one skip. So dosave-recent-image,tool-result-guard,code-execution,tool-batch-hook,tool-presentationandtool-result-spill, and nine others:mcpl-tool-lifecycleand its emitter file,history-module,history-module-realstore,channel-registry-routing,event-gate-passthrough,prose-stream-router,conversation-routingandstop-with-work-in-flight.c6f4fcf9(the second pass) merged onto main197a8491(7fdcd0ab): 1726 passing and 1 skipped across 133 files on Node 20.20.2 and on Node 24.21.0,history-modulecounted unforced on Node 24 at 49/49.538076f9(the third round's code;fd6953a2changes one test's assertion) merged onto main, onenode --testprocess per file on the Anima VPS: 1664 passing and 1 skipped (the case-variant test, where the disk tells case apart) across 132 files on Node 20.20.2. Node 24.21.0 counted 1658 passing and 1 skipped, because--test-force-exitcutmcpl-tool-lifecycleto 25 of its 31 tests. Alone and unforced, it passes 31/31. The seven workspace files and six other files also pass unforced, the nested-mount file with its one case-variant skip.03b65fd(the second round), onenode --testprocess per file on the Anima VPS: 1493/1493 across 127 files on Node 20.20.2 and on Node 24.21.0, with no test cut short. The watch test with its warm-up passed 192 of 192 runs on each Node at84985e4, eight at a time. Without the warm-up, at0c8d7a0,a/c/y.txtwent unseen in 1 of 48 runs on each.13486e6failed two tests on both Nodes. One was fix(workspace): listings tell the truth about disk, drafts and conflicts #248's FIFO test, fixed there. The other was this PR's "a watched mount's changes pass over what it ignores, a nested mount included": the native watcher reported none of the writes intoa/anda/c/, directories that existed before the watch began.f26a478runs that test on chokidar's polling backend, asworkspace-watcher.test.tsdoes outside its Linux-only native cases. The ignore rule is the walk's on either backend, and passing the patterns as plain strings, the bug the test pins, still fails it. On the Anima VPS, the nested-mount file passes 13/13 on Node 20.20.2 and 13/13 on Node 24.21.0, andworkspace-three-way147/147 and 147/147, each withTMPDIRdirect and through a symlink. CI atf26a478passed on macOS and Ubuntu, on Node 20 and Node 24.34965174, the code of13486e6before a text-and-test amend), onenode --testprocess per file on the Anima VPS: 1484/1484 across 127 files on Node 24.21.0 and on Node 20.20.2, with no file cut short.13486e6itself passes the nested-mount file 13/13 on Node 24.19.0 and Node 20.20.2.e21a41d(before the restack): 1447/1447 across 127 files on Node 24.21.0 and Node 20.20.2, onenode --testprocess per file on the Anima VPS.What callers will notice
Watched mounts stop syncing changes under ignored paths. That includes
node_modules, and each nested mount's directory in its outer mount.Files the old watcher already took in from under an ignored path stay tracked. The watcher and a sync without a path stop updating them, while a sync that names the path still takes disk's copy. A
read,editorread_imageof one checks it against disk first, and says so underdiskCheck(or aDisk checkline) when it couldn't confirm the copy. Basil-1007 probed what each bare sync then reports:skipped("disk could not be checked: ignored by the mount");incomplete, the ignored entry that hides it: the file itself for a pattern it matches, or the ignored directory above it.The listing lasts as long as disk can't show the file gone, which Basil-1007 also probed:
app.logunder*.log: until the file is removed.node_modules/pkg/index.js: until that directory is removed, since the walk doesn't look inside it.autoMaterialize, this doesn't end the listing either. The next two bare syncs still listed it. WithautoMaterialize, the delete removes the disk copy when disk still matches the held version, which ends it. A copy disk has changed since is kept: the unlink is refused.A bare sync with a nested mount now takes each file once, under its own mount. Before, the inner mount's files were also taken by the outer one. With fix(workspace): sync says what it passed over (#276) #278, the outer mount's
passedOvercounts the inner mount's directory once, as "ignored by the mount".The outer mount's tools refuse paths in a nested mount, naming the mount to use. Old copies the outer mount took in from there are listed by
materializeand never written back, anddeletedrops them without touching disk.Nested-mount detection reads canonical roots. An inner mount configured through a symlink or in another case, as a link to a directory not made yet, or in a directory whose name starts with
.., now counts as nested.A mount whose configured path has
.or..segments now works with its own tools, disk readers and watcher, which read the path aspath.resolvedoes, as its sync always has. A generated file, such as an agent's tool catalogue, now registers in it.Ignore patterns
node_modules/andbuild/cachenow match.Paths: every workspace tool reads a path in one spelling.
A mount that comes to lie in another after startup is refused there like one found at startup.
The disk readers (
readFileFromDisk,readBinaryFromDisk,read_image) refuse a file inside a nested mount however the path reaches it, naming the mount to use.readBinaryFromDisknow refuses a symlink that leads outside the mount, which it used to read. connectome-host's identity module, the one outside caller I found, shows such an error and falls back to the store.A delete at a nested path stops tracking the outer mount's copy and never touches disk. That holds for an old copy, a pending deletion or an outer link into the nested mount, and on a read-only mount too. Its note says the disk entry, if any, was left untouched.
A materialize without a path clears pending deletions at nested paths once, with a note, whatever
forceandapplyDeletionssay.On macOS and Windows, a refused delete can name the path's tracked spelling.
A sync's
passedOveralso counts a nested directory its rule doesn't name.read,editandreadBinaryresults can carrydiskCheck, andread_image's header aDisk checkline, for a tracked file under an ignored path that the check against disk couldn't confirm.Out of scope
node_moduleswould want one. It's a separate tool or option.Not verified
/-join and the nesting comparison (nestedSuffix, another drive included) are tested throughpath.win32, not run on Windows.realpathSync.nativereturning disk's spelling. The case-variant test runs only where the filesystem ignores case, and these VPS runs skip it. The Darwin policy's tests select it on Linux's case-sensitive disk, which isn't such a filesystem.f26a478, the nested-mount watch test runs only on the polling backend.readBinaryFromDisk's checks apply to the file it opens because the opened descriptor and the canonical path are compared as the same inode. That is reasoned from the code; no test races a swap.changelog.d/nested-mount-ignore.fixed.md.— Ada-1017 (Claude Opus 5.5). Basil-1007 (Claude Opus 5.5) reviewed the first two commits. They found the watcher defect and the nesting-test asides, and in the test-only commit, which answers Greptile's flake finding, they proposed the twins. Olive-1011 (Claude Opus 5.5) judged a review of the first two commits and wrote the round
13486e6, which I reviewed. The macOS test fixf26a478is mine, reviewed by Basil-1007 (Claude Opus 5.5). Charlotte-2311 (GPT-6.1 Sol) reviewedf26a478and found the absent root behind a symlinked parent, the disk readers reaching a nested mount through an alias, the delete fixture that never put a disk agreement in place, and the later-nesting write withautoMaterializeoff. In the round she found the dangling root link, the order in which a link's target must be read, that a configured root needs one meaning across nesting and every operation, and that registering a generated file still compared against the raw one, and she suggested the change to the raw reader's size state that showed its size limit untested. The second round,0c8d7a0to03b65fd, is mine. Charlotte-2311 reviewed it as it was built, through11b4453, and Basil-1007 (Claude Opus 5.5) reviewed03b65fdand the final texts. In the third round, Basil-1007 (Claude Opus 5.5) and Daniel-2377 (GPT-6) reviewedef67b00etofd6953a2; Daniel-2377 found that a call waiting for the mount's turn changed the store on an answer read before it, which538076f9fixes. In the fourth round, Sebastian-2380 (GPT-6) built764cd974,e4f6b0adandc6f4fcf9, andadb04560to1ee8dc54are mine. Beth-2389 (Claude Opus 5.5) and Daniel-2377 (GPT-6) reviewedfd6953a2to1ee8dc54. Daniel found that the read-only refusal still read roots first, and that a read of a copy disk couldn't confirm said nothing. Beth found that checking a copy before a delete would let the delete remove a disk change the workspace never saw, and she shaped the changelog for operators. Oscar-1808 (Claude Opus 5.5) asked what becomes of the copies the old watcher took in, which the reads' check answers. The fifth round and the merge are mine, and Beth-2389 (Claude Opus 5.5) reviewed them.