Skip to content

fix(workspace): the watcher holds its root on Linux, so an entry named like the root doesn't re-attach it - #528

Merged
nissa-seru merged 8 commits into
anima-research:mainfrom
nissa-seru:fix/watcher-root-identity
Oct 18, 2026
Merged

nissa-seru merged 8 commits into
anima-research:mainfrom
nissa-seru:fix/watcher-root-identity

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

On Linux, a workspace mount's watcher re-attached, closing every watch, scanning the whole mount again and running the module's catch-up pass, whenever a file or directory with the mount root's own name was made, saved over or removed anywhere in the mount. It now holds its root open on Linux, and the rename event such an entry sends runs the root check instead of forcing a re-attach. The check asks the held directory itself whether it has been removed, rather than trusting an inode number a new directory can take.

Operators will notice two things, both on Linux:

  • Unmounting. While the host watches a mount, umount of the file system under its root fails with "target is busy". umount -l still detaches it.
  • A root removed, or replaced by a rename over it, is found at the watcher's next root poll (watchRootPollMs, 2 s by default) rather than at once.

Wherever the root isn't held, as on macOS and Windows, a rename named like it still re-attaches the watcher, as on main.

Problem

The watcher should re-attach when the directory at its root's path is no longer the one it watches. main checks that two ways (src/modules/workspace/watcher.ts at 19fc2468):

  • a poll every watchRootPollMs compares the root's dev, ino and birthtimeMs with what the attach read;
  • any raw rename whose name is the root's basename forces a re-attach without comparing (:176-183). On ext4 a removed directory's inode number goes to the next directory made, so a root made again can look unchanged.

libuv names a watched directory's or file's own removal or move by its basename, and that is how the root's own rename arrives. The code is at src/unix/linux.c:2625 in libuv 1.52.1, which Node 24.22.0 ships, and the same code is in 1.44.2 (src/unix/linux-inotify.c:211), 1.45.0 and 1.46.0, the versions Node 20 ships. A rename of any entry with the root's name comes the same way: from the root's watch, from a subdirectory's, or from the file's own. On macOS, an FSEvents event for the watched directory itself that carries no modification flags arrives as a rename under its name too (src/unix/fsevents.c:283-316 in 1.52.1), such as the root's making just before its watch starts.

On main at 5f5444d6, whose two checks 19fc2468 keeps (Linux 6.12, ext4, Node 24.22.0, chokidar 4.0.3), with a root named foo and the root poll past the run:

  • Same-named entries. Each of these re-attached the watcher once: a file foo in the root made, deleted, or saved by renaming over it; a directory foo made in the root; and a file sub/foo made or deleted. A file rewritten in place didn't, nor did a file named otherwise.
  • Inode numbers. On the same disk, rmdir d; mkdir d gave d its old inode number 200 times in 200, and its old birth time too 199 times in 200, d having been made just before. With the old d held open, it gave the number 0 times in 200.
  • A watched root removed and made again at once after the watcher started came back with the same inode and the same birthtimeMs.

Changes

  • On Linux, the watcher holds its root open from each attach until it lets go of it, and reads the root's identity from that descriptor. The root it holds is the one chokidar watches and the root check stats: the configured path as path.resolve gives it, a link in it kept, as main now reads it throughout the watcher. It opens the root only as a directory, so a FIFO or a file at the root's path fails at once (ENOTDIR), where a plain open of a FIFO would block until a writer came (fifo(7)).
  • The root check re-attaches a held root when the identity at its path differs from the held one's, or when the held directory has been removed: its link count is 0, or it can't be read. That holds whatever the path's own stat answers, so a removed root whose path fails with another error than ENOENT, as when a parent becomes a file, is let go of too. The link count answers whatever a new directory at the path has taken, which matters where the inode numbers are a server's, as on NFS, where, by our reading, the client's hold pins nothing. A removal by another NFS client shows once this client revalidates its cached attributes (see "Not verified").
  • A rename named like a held root runs that check at once. Where the root isn't held, the rename still forces a re-attach, as on main, since an identity read from the path can match a root made again. The root isn't held off Linux, and on Linux where it can't be opened as a directory, as without read permission, at the process's file limit, or when the path isn't a directory. Nor is it held where its link count reads 0 as it's opened: if that's the file system's count for a directory in place, a later check would take the root for removed and re-attach at every poll.
  • The watcher lets go of the root at a re-attach and at the stop. At the stop it does so before closing chokidar, so a stop whose close fails still lets go. It also lets go when a check finds the root gone, where a read-only mount waits detached, and when an attach fails after the open. An error closing the descriptor is reported, and what called the release goes on, since Linux frees the descriptor whatever close returns (close(2)).
  • A re-attach whose close of the old chokidar instance fails reports that and attaches again. By our reading of chokidar 4.0.3's close() (esm/index.js:389-415), it removes its listeners before it closes, so the old instance reports nothing more. On main the watcher was then left with no instance, and a poll that found the root unchanged attached nothing.
  • A rename without a name is passed over. libuv gives one for a file whose path it can't read (src/unix/kqueue.c:492-499, macOS's backend for files). On it, main's handler threw TypeError: Cannot read properties of null (reading 'endsWith') inside chokidar's event.
  • A root moved away and back before its renames are handled is the directory the watcher holds, so the check finds it unchanged and the watcher doesn't re-attach, where main forced a re-attach at the move's rename. Its watches are on the same directory, and a write after it is reported.

What operators will notice, on Linux, filed as a breaking change for operators (changelog.d/watcher-holds-root.breaking.md), since one who unmounts under a watched root has to act:

  • Unmounting. umount of the file system under a watched root fails with "target is busy" while the host watches it, and umount -l still detaches it. In a user and mount namespace (unshare -rm) over tmpfs, on Linux 6.12 with Node 24.22.0, umount succeeded under chokidar alone and failed with "target is busy" under this watcher at 3707cc3, where umount -l succeeded.
  • A root removed, or replaced by a rename over it, is found at the next root poll rather than at its rename. While the root is held, its removal sends its watch no rename, since the kernel sends that event only when the directory's last user lets it go (fs/dcache.c:2403-2410 in Linux 6.12). On ext4 none came. A root moved away is still found at its rename.

Holding a root on a Windows drive under WSL 2 doesn't stop Windows programs renaming or removing the folder. On WSL 2 (Linux 6.18, Node 24.19.0), with a directory on /mnt/c held open as the watcher holds it, Windows' cmd.exe renamed it and then removed it, as with nothing held.

Changelog: changelog.d/watcher-root-name-renames.fixed.md and changelog.d/watcher-holds-root.breaking.md.

Tests

In test/workspace-watcher.test.ts:

  • New, on Linux:
    • an entry named like the held root, made, saved over or removed anywhere beneath it, doesn't re-attach the watcher: a file made in the root and in a subdirectory, one saved over by a rename, both removed, and a directory made;
    • the watcher holds its root: a root removed and made again takes another inode number, the watcher re-attaches to it and then holds only the new one, and a stopped watcher holds nothing of its root, as /proc/self/fd shows. It skips where the disk gives a removed directory's number to no new one in five tries, as on tmpfs;
    • the identity is read from what's held: a root replaced inside the open, by a stubbed openSync, is found by a poll, and the watcher then holds the new root and nothing of the one moved away;
    • the root held and checked is the one watched: with a link followed by .. in the configured path, which the kernel opens as another directory, the watcher holds the resolved root, and three root polls leave it attached;
    • a stop, and a re-attach, whose close of chokidar fails still let go of the root;
    • an error closing the root is reported, and the stop still closes chokidar;
    • a held root removed while the root made again reads as the removed one, by a stubbed statSync, as on a file system whose numbers a server gives out, is re-attached; so is a held root whose fstatSync fails;
    • a held root removed while its path fails with ENOTDIR, by a stubbed statSync, is let go of, and one in place whose path fails with EACCES stays held, with no re-attach over three root polls;
    • a root opened whose fstatSync then fails isn't held;
    • a root whose link count reads 0, by a stubbed fstatSync, isn't held, and three root polls leave it be;
    • a file at the root's path isn't held;
    • a root moved away and back at once goes on reporting a write, and the watcher doesn't re-attach;
    • a root moved away is re-attached at its rename, the root poll being past the test, and a file written to the root the mount makes again is reported.
  • New, everywhere:
    • a root the watcher doesn't hold, on Linux one it can't open and on other platforms any root, is left be by a rename named otherwise and by three root polls, is re-attached at a rename named like it, and is re-attached by a poll when a directory is put in its place. It keeps chokidar's polling backend for every instance it makes, and that backend sends no raw rename, so only a poll can find that directory;
    • a re-attach whose close of chokidar fails, over a root that hasn't changed, attaches again and reports a later write;
    • a rename event without a name is passed over.
  • Changed:
    • "re-attaches after the root is removed and recreated" lets the root poll find the root made again, since a held root's removal sends no rename;
    • "keeps a deleted read-only root detached" also checks that the watcher lets go of the root it found gone.

In test/workspace-three-way.test.ts, changed:

  • the test of a re-attached watcher's catch-up pass makes the re-attach itself, as the watcher does at a rename named like a root it can't hold, and changes gate.json once the old chokidar instance has begun to close. It used to move the root away and back, which a held root now finds unchanged;
  • the test of a re-attach just after ready makes it itself too, since on Linux the rename it sent no longer forces one.

The head, 29c58c5, adds to 3707cc3 only a comment. These ran on Linux (Linux 6.12, ext4, Node 24.22.0 with libuv 1.52.1, chokidar 4.0.3) at 3707cc3, which has main 19fc2468 merged in:

  • the build, whose tsc covers the tests, is clean;
  • workspace-watcher passes 36 of 36, twice, and workspace-three-way 352 of 352;
  • with main's watcher, 15 of the 36 fail, each a test that pins this change: the same-named entries, the held root and its identity, the root held and checked when a link and .. are in its path, both failed closes and the re-attach after one, the close error, the nameless rename (which throws there), the held root that can't be read, the removed root whose path fails with another error and the root in place whose path does, the capture that can't read, the root moved away and back, and the read-only root let go of. Five new tests pass there too, since they pin what main does and this change keeps: a root not held, a root moved away, a file at the root's path, a root whose link count reads 0, and the held root removed while the path reads as the removed one, which main's forced rename finds. workspace-three-way passes 352 of 352 there, since its changed tests are about the module's pass at a re-attach, not what causes one.

Of the open pull requests that touch the workspace module, #533, #463 and #465 merge cleanly with this change and with main 19fc2468, and #536, #401 and #271 each conflict with that main already, in the same files with this change as without it. With #533's head 112a8bf merged into 3707cc3, the build is clean, the watcher file passes 36 of 36, the three-way file 354 of 354, and #533's own tests pass: workspace-read-file-from-disk 46 of 46 and tool-presentation 14 of 14. With #463's head 4da02df merged in instead, the build is clean, the watcher file passes 36 of 36, the three-way file 355 of 355, and #463's own tests pass: workspace-edit-plan 25 of 25, workspace-edits 38 of 38, workspace-file-tools 43 of 43, workspace-text 3 of 3 and tool-call-result-max-chars 5 of 5. workspace-path-spelling passes 8 of 8 there, its other 17 skipping where the disk tells case apart, as they say. With #465's head fd7a44b merged into the head, 29c58c5, the build is clean, the watcher file passes 36 of 36, the three-way file 359 of 359, and #465's own tests pass: workspace-edit-plan 25 of 25, workspace-edits 38 of 38, workspace-file-tools 54 of 54, workspace-read-text 14 of 14, workspace-text 4 of 4 and tool-call-result-max-chars 5 of 5, with workspace-path-spelling again 8 of 8 and 17 skipped.

Each piece taken out fails a test at 3707cc3, 24 in all:

  • holding the root nowhere fails thirteen tests, the same-named and held-root tests among them;
  • opening the root as anything but a directory fails the test of a file at the root's path, and an open that throws, where it would read the root unheld, fails the test of a root not held;
  • reading the identity from the path rather than the descriptor fails the test of a root replaced as it was opened, and an identity that always matches fails that test and the test of a root not held;
  • holding the configured path as written, or checking it, rather than the resolved root fails the test of a link and .. in that path;
  • the link count taken out fails the test of a held root removed while the path reads as the removed one and the test of a removed root whose path fails with another error. A held root that can't be read counted as not removed fails its test, a removed root kept when its path fails with another error fails that test, and holding a root whose link count reads 0 fails its test;
  • not letting go at a re-attach fails three tests, not letting go when the root is found gone fails the read-only test and the test of a removed root whose path fails with another error, and not letting go after a failed capture fails its test;
  • not letting go at the stop fails three tests, and letting go there after the close fails the failed stop close test;
  • a close error thrown rather than reported fails the close-error test, and a failed close of the old instance that skips the attach fails the test of a re-attach after one;
  • letting go at any error from the path, as at ENOENT, fails the test of a root in place whose path fails with another error;
  • the rename forcing nowhere fails the test of a root not held, and forcing everywhere fails the same-named and moved-away-and-back tests;
  • passing a nameless rename on fails its test, taking the rename's check out fails the tests of a root not held and of a root moved away, and checking at a rename of any name fails the test of a root not held.

What each part is for

The watcher should re-attach when the directory at its root's path isn't the one it watches, and not otherwise. Where it can't tell, because the root isn't held, a rename named like the root still forces a re-attach, as on main. Each re-attach closes every watch, scans the mount again and runs the module's catch-up pass.

  • Holding the root, on Linux. The held directory can say whether it has been removed, which no identity read from the path can, and while it's held no local directory made at the path takes its number. It costs a descriptor per watched mount, the unmount refusal, and a removal found at the poll rather than at its rename. Without it the rename has to keep forcing.
  • Asking the held directory whether it was removed, at each check, whatever the path answers. One fstatSync per check. Without it, a file system whose numbers a server gives out could hand a root made again the removed one's number, and the poll would leave the watcher on the removed directory, where main's forced rename re-attached.
  • Holding no root whose link count reads 0 as it's opened. One comparison. A live directory read 2 on ext4 and tmpfs and 1 on WSL's drvfs, but on a file system that reported 0, a held root would be re-attached at every poll.
  • Opening the root only as a directory. A FIFO at the path would otherwise block the process.
  • Reading an unheld root from its path. A root that can't be held would otherwise have no identity, and every poll would attach to it again.
  • The rename's check, and its force where the root isn't held. The check finds a moved root at once: with the rename taken out and the poll at 60 s, a root moved away for 300 ms and back left the watcher reporting nothing of a later write. The force keeps main's behaviour wherever the root isn't held.
  • Letting go at each way the watcher leaves a directory, at the stop before closing chokidar, so no hold outlives what it was for, and reporting a close's error rather than stopping on it.
  • Attaching again after a failed close of the old instance. A try around one await. Without it, a mount stops being watched until its root's identity changes. Its cost is that a close that fails can leave some of the old instance's watches open, with nothing listening to them, once per failure. That rests on reading chokidar 4.0.3's close() (esm/index.js:389-415), which calls each closer in turn, so a closer that throws leaves the ones after it unclosed, and one that rejects leaves only its own watch.
  • Passing over a nameless rename. One check, where main threw.
  • Cut or set aside:
    • forcing only on Linux, which left every case above on Linux;
    • forcing only for renames from the root's own watch, read from chokidar's details.watchedPath, which chokidar documents as internal. A child named like the root still comes from the root's watch;
    • dropping the rename and checking at the poll alone, which left the moved root above unnoticed;
    • holding the root off Linux, where nothing here showed a removed directory's number coming back;
    • main's isRootPath(rawPath) clause: the raw path is an entry's name, never the root's absolute path, so it never matched.

Not verified

  • The rest of the suite. Only the test files named above ran here. CI runs the whole suite on this pull request.
  • macOS and Windows. Nothing here ran on either. There the root isn't held, and a rename named like it forces a re-attach as on main. CI's macOS legs run the tests that don't need Linux.
  • Network file systems. Nothing ran on one, and what the link count shows there rests on reading the Linux NFS client. A removal by this host clears the removed directory's link count at once (fs/nfs/dir.c:2492-2495 in Linux 6.12). A removal by another client shows only once this client revalidates the held directory's cached attributes, and an fstat that then fails on the stale handle counts as removed. Until then, a root made again with the removed one's number and no birth time looks unchanged.
  • Other local file systems. The hold and the link counts were run on ext4, tmpfs and WSL's drvfs only.
  • A real unmount. The refusal and umount -l were run over tmpfs in a user and mount namespace, not on a device.

Out of scope

  • macOS's echo. An FSEvents event for the root itself still forces a re-attach there, as on main, since the root isn't held. By our reading of libuv 1.52.1's fsevents.c (:281-286, :801-808), that event is named after the basename of the root's real path, so for a root whose last component is a link it matches neither main's check nor this one, and the root poll still finds a changed identity.
  • A root swapped and swapped back during an attach. chokidar starts its watch a few milliseconds after the watcher opens the root. A root replaced in that window and put back before the next poll leaves chokidar on the directory that replaced it, while the path matches the held root. main's forced rename re-attached at that directory's removal or move, and here the check finds the root unchanged.
  • stop() and then start() of one watcher while a re-attach awaits the old instance's close attaches twice, as on main. The module never starts a stopped watcher again: it makes a new one.
  • A read-only root found gone keeps its chokidar instance until a check finds the root back, as on main.
  • A stop whose close of chokidar fails rejects, as on main, and the module's stop goes no further, so the watchers of the mounts after it keep running, and now also hold their roots.

  • Changelog fragments added: changelog.d/watcher-root-name-renames.fixed.md, changelog.d/watcher-holds-root.breaking.md

Found by Nell-1783 while tracing a macOS test failure, whose reading of libuv's FSEvents path this rests on; Ada-1017 settled the design. Our internal review read each of the first three commits, each later one answering the review of the one before. The fourth adds a test and comments. After the merge of main, a further test pins that the root held is the root watched. Nell-1783 read each round of code and the merge, probed each way the first two commits' watcher lets go of its root, showed the network file system case, and gave the last commit's comment its wording. Written by Hilda-2580.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

Hilda-2580 and others added 5 commits October 18, 2026 10:35
…d like the root doesn't re-attach it

On Linux the watcher forced a re-attach (close every watch, scan the mount
again, run the module's catch-up pass) at any raw rename whose name was the
root's basename. libuv names a watched directory's or file's own removal
or move by its basename (src/unix/linux.c:2625 in libuv 1.52.1), so a rename
of any entry with the root's name, anywhere in the mount, came the same way
as the root's own: a file or directory named like the root made, saved over
or removed. The force existed because on Linux a removed directory's inode
number goes to the next directory made (ext4: 200 of 200), and its birth
time can match too (199 of 200 for a directory made just before), so the
poll's identity couldn't tell a root made again from the old one.

Now, on Linux, the watcher holds its root open from each attach until the
next attach or the stop, and reads the root's identity from that
descriptor. A number held open isn't given out (0 of 200), so the identity
is exact, and a rename named like a held root runs the poll's check at once
instead of forcing. Where the root isn't held (macOS, Windows, or a Linux
open that failed, as without read permission or at the file limit), its
identity is read from its path as before, and the rename still forces.
A rename without a name, which libuv's kqueue backend gives for a file
whose path it can't read, is passed over; main threw a TypeError from
isRootPath(null) inside chokidar's event. main's isRootPath(rawPath) clause
goes: the raw path is an entry's name, never the root's absolute path.

Costs, on Linux:
umount of the file system under a watched root fails "target is busy"
(umount -l still detaches it), and a root removed and made again is found
at the next poll rather than at its rename, since a held directory's
removal sends no rename until the hold ends.

Tests: workspace-watcher gains six (same-named entries on Linux; the held
root's inode number and /proc descriptors through a re-attach and a stop;
a root not held, by an openSync stub on Linux; a nameless rename; a root
moved away; a stop whose chokidar close fails), and "re-attaches after the
root is removed and recreated" now lets the poll find it. In
workspace-three-way, the re-attached-watcher test makes its re-attach itself
and changes gate.json while the old instance closes, since a held root moved
away and back is found unchanged, and the test of a re-attach just after
ready makes it itself too, since the rename it sent no longer forces one on
Linux.

Found by Nell-1783 while tracing a macOS test failure; Ada-1017 settled
the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very way out lets go of it

Round 2, answering the internal review of the first round and Nell-1783's
read of it.

- The poll now also asks the held directory whether it has been removed:
  fstat's link count is 0 once it has (ext4 and tmpfs, after rmdir and
  after a rename over it; 2 when it is moved away), and an fstat that fails
  counts as removed. On a file system whose inode numbers a server gives
  out, as NFS, the client's hold pins nothing, so a root the host removes
  and makes again could take the old number and look unchanged; the NFS
  client clears the removed directory's link count (Linux 6.12
  fs/nfs/dir.c:2492-2495).
- holdRoot opens the root with O_RDONLY|O_DIRECTORY, so a FIFO or a file
  at the root's path fails ENOTDIR at once instead of blocking or being
  held.
- The watcher lets go of the root before closing chokidar at a re-attach
  as at the stop, when a check finds the root gone (a read-only mount waits
  detached, holding nothing), and when a capture fails after the open; the
  release moves out of the start of captureRootIdentity. releaseRoot
  reports an error from close and goes on, since Linux frees the
  descriptor whatever close returns.
- Comments: the raw handler says a held root's removal sends no rename;
  checkRootLiveness says what a gone root does on each mode; sameIdentity
  says what birthtimeMs does and doesn't tell.

Tests: new ones for the identity read from the held descriptor (a root
replaced inside the open), a re-attach whose chokidar close fails, a
close error at the stop, the link count with the path reading as the
removed root, a held root whose fstat fails, a capture whose fstat fails,
a file at the root's path, and a root moved away and back at once. The
read-only test checks the root is let go of; the failed-close stop test
checks the root was held; the unheld test counts polls instead of
sleeping, judges renames at once, and adds a replacement a poll finds;
the nameless-rename test judges at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, a root whose link count reads 0 isn't held, and a failed close still re-attaches

Round 3, answering the internal review of round 2.

- checkRootLiveness treats a held root that has been removed (link count 0,
  or an fstat that fails) as gone when its path's stat fails with another
  error than ENOENT, such as ENOTDIR when a parent becomes a file, so the
  watcher lets go of the removed directory instead of holding it. The
  path's error is still reported at each poll, as on main.
- holdRoot doesn't hold a root whose link count reads 0 as it's opened,
  since there the count can't tell a removed root from one in place: it
  closes the descriptor and reads the root from its path, as where the
  root can't be opened, so a rename named like it forces a re-attach as on
  main. A live directory read 2 on ext4 and tmpfs and 1 on WSL's drvfs.
- reattach reports a failed close of the old chokidar instance and attaches
  again. chokidar removes its listeners before it closes, so the old
  instance reports nothing more. Before, as on main, the watcher was left
  with no instance, and a poll found the root's identity unchanged and
  attached nothing.

With the failed close caught, letting go of the root before or after the
close comes to the same at a re-attach, so its comment about the order goes.

Tests: new ones for a re-attach whose close fails over an unchanged root,
a removed held root whose path fails with ENOTDIR, and a root whose link
count reads 0. The moved-away-and-back test asserts no re-attach, and the
close-error test drops a sleep its emit check already covered. The test of
a root not held keeps chokidar's polling backend for every instance it
makes (withPollingThroughout): withPollingBackend covers only the first, so
the native instance a re-attach made sent the move's raw rename, and that
rename, not a poll, found the directory put in the root's place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…with another error; comments on the link count's limits

Round 4, answering the internal review of round 3.

- A new test pins the other side of round 3's catch: a held root that
  hasn't been removed, whose path fails with EACCES as when a parent loses
  search permission, stays held, and three root polls don't re-attach it.
  Letting go on any error but ENOENT would fail it.
- holdRoot's comment, and the test's, say why a root whose link count reads
  0 as it's opened isn't held: if that's the file system's count for a
  directory in place, a later check would take the root for removed and
  re-attach at every poll.
- heldRootRemoved's comment says a removal by another NFS client shows once
  this client revalidates the held directory's cached attributes.
- The held-root test asks up to five times whether the disk gives a
  removed directory's number to the next one made, since another process
  can take it first on a busy disk; with one try it skipped in three
  removal runs on a loaded machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s who unmount under a watched root

watcher-holds-root.changed.md becomes watcher-holds-root.breaking.md. An
operator who unmounts the file system under a watched root while the host
runs now has to act (stop the host first, or use umount -l), which
CONTRIBUTING files as breaking. The entry now says what stops working,
what gets slower and what is unchanged. The fixed fragment points at
Breaking instead of Changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge with its documented Linux behavior changes.

Summary

On Linux, the watcher holds its root open and checks whether that directory still matches the configured path. Entries named like the root no longer force a full re-attach.

  • A Linux watcher checks its held root before restarting on a same-name rename.
  • A workspace watcher attaches again when its old watcher fails to close.

Author acknowledgments retained:

  • nissa-seru intentionally accepts busy ordinary unmounts and poll-delayed detection of removed or renamed-over roots; both are documented as breaking changes.
  • nissa-seru intentionally avoids re-attaching when the same root moves away and back before its events are handled.
  • nissa-seru defers a root swapped and restored during attachment, which can leave chokidar watching the temporary replacement.
  • nissa-seru defers overlapping stop()/start() on one watcher; the module creates a new watcher instead.
  • nissa-seru leaves macOS’s root-event re-attach and a missing read-only root’s retained chokidar instance unchanged.
  • nissa-seru acknowledges that a failed module shutdown can leave later mounts running and now holding their roots.
  • nissa-seru acknowledges delayed NFS attribute updates and unused watches left behind by a partially failed chokidar close.

Reviews (1) · Last reviewed commit: "changelog(workspace): the root hold is a..." · Reviewed by Greptile

Hilda-2580 and others added 3 commits October 18, 2026 15:47
main now resolves a mount's root once, as MountWatcher.root (the configured
path resolved), and reads that everywhere in the watcher. The conflicts were
in the imports, the raw rename handler and readRootIdentity. Each keeps this
branch's code, reading this.root where it read this.config.path. holdRoot's
open merged without a conflict and now opens this.root too, so the directory
the watcher holds is the one chokidar watches and the root check stats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n a link and .. in its path lead elsewhere

With the root resolved once, the directory the watcher holds, the one its
root check stats and the one chokidar watches are the same. A configured
path with a link followed by `..` is where that shows: the kernel opens
another directory for the path as written than its resolved form names.
The test configures such a path and asserts that the watcher holds the
resolved root and that three root polls leave it attached. Holding or
checking the path as written fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MountWatcher.root's comment named making, watching and checking the root.
On Linux the watcher also holds it open, and the hold has to be on that
same directory, so the comment names holding too. Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit f6683eb into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…and anima-research#372

main aae86b7 brings anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, anima-research#539, anima-research#372 and anima-research#210.

One conflict, a comment in src/framework.ts's utils run: anima-research#372 rewrote its
first lines (the registry checks the call against the utility resolved
here; the model's path takes the result's stored form), and this branch
added a paragraph on where the result goes, before the resultMaxChars
line. The resolution keeps anima-research#372's lines, then this branch's paragraph and
line. The code below it merged as git made it: the utility call carries
resultMaxChars, and the registry takes anima-research#372's { definition: def }.

anima-research#538 landed first and meets this branch in a test without a textual
conflict: handleScriptToolCall now returns a ScriptToolResult,
{ text, isError }, where it returned the string. The script case of
tool-call-result-max-chars.test.ts reads .text, and checks isError is
false, as anima-research#538's own tests read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back
(anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f.

- changelog.d/403-truncation-image-slots.fixed.md: main deleted it when
  0.22.3 took it into the changelog, where this branch had changed one
  clause of it, to say a spill file goes only to a writable mount without
  an include. Taken as deleted: 0.22.3's entry stays as released, and
  this change's own fragment (mount-include.added.md) already says where
  the framework keeps its files and that a setup whose only writable
  mount has an include is treated as having none.

git merged the rest on its own. Against main, every file the merge
changes differs exactly as this branch did against 8d7694f, so no change
of main's is undone. Of the files both sides changed, src/framework.ts,
src/types/framework.ts and src/types/trace.ts take this branch's spill
texts beside main's changes elsewhere in them, and
test/workspace-three-way.test.ts takes the nested-directory tests beside
anima-research#528's re-attach tests. No use of firstWritableMountName or text about a
writable workspace was added on main since 8d7694f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back
(anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f.

- changelog.d/403-truncation-image-slots.fixed.md: main deleted it when
  0.22.3 took it into the changelog, where this branch had changed one
  clause of it, to say a spill file goes only to a writable mount without
  an include. Taken as deleted: 0.22.3's entry stays as released, and
  this change's own fragment (mount-include.added.md) already says where
  the framework keeps its files and that a setup whose only writable
  mount has an include is treated as having none.

git merged the rest on its own. Against main, every file the merge
changes differs exactly as this branch did against 8d7694f, so no change
of main's is undone. Of the files both sides changed, src/framework.ts,
src/types/framework.ts and src/types/trace.ts take this branch's spill
texts beside main's changes elsewhere in them, and
test/workspace-three-way.test.ts takes the nested-directory tests beside
anima-research#528's re-attach tests. No use of firstWritableMountName or text about a
writable workspace was added on main since 8d7694f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…his branch's content arrays

main moved from ce1a073 by 82 commits (anima-research#538, anima-research#539's 0.22.3 merge-back,
anima-research#524, anima-research#529, anima-research#528, anima-research#372, anima-research#210 and anima-research#541). Two conflicts:

- src/framework.ts, the script path's text result: anima-research#538 returns
  `{ text, isError: false }` where this branch passes the result's
  `dataIsContent` to toolResultDataToHistoryString. Both are kept.
- test/script-structured-references.test.ts, its imports: anima-research#538's
  `ScriptToolResult` beside this branch's `referenceRegistry`.

Read beside the conflicts, as they stand after the merge: anima-research#538's
scriptStructuredResult, which still takes the content array the dispatch
gave the call, where an MCPL peer's blocks were mapped through
referenceBlockForScript, and the reference registry's address sites in
start(), stop(), setMcplServerConfig and a server's removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant