Repository navigation
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 intoOct 18, 2026
Conversation
…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>
[Medium impact] The PR appears safe to merge with its documented Linux behavior changes. SummaryOn 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.
Author acknowledgments retained:
Reviews (1) · Last reviewed commit: "changelog(workspace): the root hold is a..." · Reviewed by Greptile |
This was referenced Oct 18, 2026
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
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>
1 task done
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
umountof the file system under its root fails with "target is busy".umount -lstill detaches it.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.
mainchecks that two ways (src/modules/workspace/watcher.tsat19fc2468):watchRootPollMscompares the root'sdev,inoandbirthtimeMswith what the attach read;renamewhose 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:2625in 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-316in 1.52.1), such as the root's making just before its watch starts.On
mainat5f5444d6, whose two checks19fc2468keeps (Linux 6.12, ext4, Node 24.22.0, chokidar 4.0.3), with a root namedfooand the root poll past the run:fooin the root made, deleted, or saved by renaming over it; a directoryfoomade in the root; and a filesub/foomade or deleted. A file rewritten in place didn't, nor did a file named otherwise.rmdir d; mkdir dgavedits old inode number 200 times in 200, and its old birth time too 199 times in 200,dhaving been made just before. With the olddheld open, it gave the number 0 times in 200.birthtimeMs.Changes
path.resolvegives it, a link in it kept, asmainnow 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)).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").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.closereturns (close(2)).close()(esm/index.js:389-415), it removes its listeners before it closes, so the old instance reports nothing more. Onmainthe watcher was then left with no instance, and a poll that found the root unchanged attached nothing.src/unix/kqueue.c:492-499, macOS's backend for files). On it,main's handler threwTypeError: Cannot read properties of null (reading 'endsWith')inside chokidar's event.mainforced 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:umountof the file system under a watched root fails with "target is busy" while the host watches it, andumount -lstill detaches it. In a user and mount namespace (unshare -rm) over tmpfs, on Linux 6.12 with Node 24.22.0,umountsucceeded under chokidar alone and failed with "target is busy" under this watcher at3707cc3, whereumount -lsucceeded.fs/dcache.c:2403-2410in 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/cheld open as the watcher holds it, Windows'cmd.exerenamed it and then removed it, as with nothing held.Changelog:
changelog.d/watcher-root-name-renames.fixed.mdandchangelog.d/watcher-holds-root.breaking.md.Tests
In
test/workspace-watcher.test.ts:/proc/self/fdshows. It skips where the disk gives a removed directory's number to no new one in five tries, as on tmpfs;openSync, is found by a poll, and the watcher then holds the new root and nothing of the one moved away;..in the configured path, which the kernel opens as another directory, the watcher holds the resolved root, and three root polls leave it attached;statSync, as on a file system whose numbers a server gives out, is re-attached; so is a held root whosefstatSyncfails;ENOTDIR, by a stubbedstatSync, is let go of, and one in place whose path fails withEACCESstays held, with no re-attach over three root polls;fstatSyncthen fails isn't held;fstatSync, isn't held, and three root polls leave it be;In
test/workspace-three-way.test.ts, changed:gate.jsononce 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 head,
29c58c5, adds to3707cc3only a comment. These ran on Linux (Linux 6.12, ext4, Node 24.22.0 with libuv 1.52.1, chokidar 4.0.3) at3707cc3, which hasmain19fc2468merged in:tsccovers the tests, is clean;workspace-watcherpasses 36 of 36, twice, andworkspace-three-way352 of 352;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 whatmaindoes 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, whichmain's forced rename finds.workspace-three-waypasses 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
main19fc2468, and #536, #401 and #271 each conflict with thatmainalready, in the same files with this change as without it. With #533's head112a8bfmerged into3707cc3, 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-disk46 of 46 andtool-presentation14 of 14. With #463's head4da02dfmerged 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-plan25 of 25,workspace-edits38 of 38,workspace-file-tools43 of 43,workspace-text3 of 3 andtool-call-result-max-chars5 of 5.workspace-path-spellingpasses 8 of 8 there, its other 17 skipping where the disk tells case apart, as they say. With #465's headfd7a44bmerged 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-plan25 of 25,workspace-edits38 of 38,workspace-file-tools54 of 54,workspace-read-text14 of 14,workspace-text4 of 4 andtool-call-result-max-chars5 of 5, withworkspace-path-spellingagain 8 of 8 and 17 skipped.Each piece taken out fails a test at
3707cc3, 24 in all:..in that path;ENOENT, fails the test of a root in place whose path fails with another error;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.fstatSyncper 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, wheremain's forced rename re-attached.main's behaviour wherever the root isn't held.tryaround oneawait. 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'sclose()(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.mainthrew.details.watchedPath, which chokidar documents as internal. A child named like the root still comes from the root's watch;main'sisRootPath(rawPath)clause: the raw path is an entry's name, never the root's absolute path, so it never matched.Not verified
main. CI's macOS legs run the tests that don't need Linux.fs/nfs/dir.c:2492-2495in Linux 6.12). A removal by another client shows only once this client revalidates the held directory's cached attributes, and anfstatthat 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.umount -lwere run over tmpfs in a user and mount namespace, not on a device.Out of scope
main, since the root isn't held. By our reading of libuv 1.52.1'sfsevents.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 neithermain's check nor this one, and the root poll still finds a changed identity.main's forced rename re-attached at that directory's removal or move, and here the check finds the root unchanged.stop()and thenstart()of one watcher while a re-attach awaits the old instance's close attaches twice, as onmain. The module never starts a stopped watcher again: it makes a new one.main.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.d/watcher-root-name-renames.fixed.md,changelog.d/watcher-holds-root.breaking.mdFound 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-seruaccount.