Repository navigation
fix(workspace): forced removal unlinks only a regular file or a link to one - #496
Merged
nissa-seru merged 2 commits intoOct 18, 2026
Merged
Conversation
…to one
unlinkContained, which applies a workspace deletion to disk, refused, of
the kinds of entry it could find there, only a directory, a symlink on a
mount that doesn't follow them, and a followed one it can't resolve inside
the mount. With `force` (and `applyDeletions`), which skips the checks of
what disk's copy holds but never the boundary, a pipe, socket or device
found at a deleted path was unlinked, and so was a followed symlink to a
directory or to anything else that isn't a regular file. effects.ts's
header says an effect acts on "a regular file inside the mount", and
writeContained already refuses those entries, forced or not (checkOpened:
"not a regular file"). The push's refusal before any force even offered it:
for a deletion whose disk copy is "not a regular file", forcedWay named a
forced materialize to delete it.
Now the unlink refuses them too:
- a pipe, socket or device at the path;
- a followed symlink whose target isn't a regular file (a directory, or a
pipe, socket or device), since a followed link stands for what it leads
to.
What it removes is a regular file, or a followed symlink to a regular file
inside the mount, whose link alone goes, never the file it leads to. A
refusal touches nothing, so the path's evidence goes back as it was.
unlinkContained's doc now says what it removes and what it refuses.
Each refusal uses observation's words for the path ("not a regular file",
"a symlink to a directory, not walked"), so inTheWay, which already answers
a directory in the way of a push forced or not, answers a forced unlink and
an unforced deletion alike: "disk has a pipe, socket or device at this
path, or a symlink to one" (or "a symlink to a directory at this path"),
"and no push unlinks it, forced or not — ask whoever runs this host if it
should go". forcedWay no longer offers force for a deletion of what isn't
a regular file, and its doc says force stops there.
For an operator, a forced materialize with applyDeletions no longer clears
such an entry. The changelog fragment says to remove one by hand with
`unlink <path>`, which removes that one entry, never what a link leads to,
and refuses a directory, rather than with `rm -r` on a link to a directory
written with a trailing slash, which acts on the directory. The window every
unlink has, an entry put at the path between the last check and the unlink,
is unchanged, and the fragment says so.
Tests:
- workspace-three-way.test.ts: on a mount that follows symlinks, paths the
workspace deleted where disk now holds a FIFO, a socket, a symlink to a
directory and a symlink to a FIFO. A materialize with applyDeletions
refuses each, with no force offered, and a forced one refuses each in the
same words; all stay on disk with what the links lead to. A symlink to a
regular file there is removed by the forced materialize, and the file it
led to stays.
- workspace-file-tools.test.ts: the deletion of a file disk replaced with a
FIFO, which named a forced materialize that unlinked it, now says no push
unlinks it, and the forced materialize leaves it, saying so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… isn't left for applyDeletions Answers an internal review of the forced-removal change. - A forced materialize without applyDeletions listed a workspace deletion over a pipe, socket or device, or a followed symlink to one or to a directory, among the deletions left on disk, and its note said materialize with applyDeletions removes them, which the unlink refuses forced or not. A directory there was listed the same way before. Such a deletion is now refused in inTheWay's words, as an unforced push already refuses it, rather than listed among the deletions left on disk. - Confirming an earlier unlink named whatever appeared at the path since, other than a directory or a symlink, "a file". A pipe, socket or device is now named as one. - unlinkContained's comment names the two refusals that use observation's words, since the followed-link block's other refusals don't. - PushOptions.force and pushPaths' doc say that, forced or not, nothing but a regular file, or a followed symlink to one, is written over or unlinked. - The unreleased changelog fragment for the workspace file tools said a deletion of something that isn't a regular file is offered force, which this change removes; that sentence goes. This change's fragment says what a forced materialize without applyDeletions now does, and that a path whose branch intent can't be read is refused or left as before. Tests: workspace-three-way.test.ts's forced-removal test adds a directory at a deleted path and a forced materialize without applyDeletions, which refuses each entry in the same words and leaves only the link to a regular file for applyDeletions. A new test confirms an earlier unlink over a FIFO that appeared since: it's named as a pipe, socket or device and left, and the unlink is still owed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Required label not found on this PR. |
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…eps its class, outcome and data, with W4a's words main moved from 0d19f7c to 6dfea06 (trains K and L: anima-research#445, anima-research#494, anima-research#496, anima-research#497, anima-research#499, anima-research#500, anima-research#502, anima-research#503, anima-research#504, anima-research#505, anima-research#247, anima-research#507, anima-research#509). Only anima-research#247 conflicts, in server-connection.ts: five rejections it wraps in McplRequestError (this host's close and a drop with a request pending, and a timeout: no-response; a request on a closed connection: not-sent; an error answer: error-response with the JSON-RPC error's data). Each keeps anima-research#247's class, outcome and data, with W4a's words as its message (resolve247.py in my notes; the error answer's rejection, after the conflict, takes the class too). Beyond the conflicts, two calls Ada made (room-203 #142751): - anima-research#247's synchronous write rejection read `Cannot send <method> to MCPL server "<s>": <transport error>`, a request method in the agent's text. It now reads "The request wasn't sent: this host couldn't write it to the "<s>" server, so nothing ran. Try again later.", and the method, id and the transport's words go to a log line (logDetail). - publishErrorOutcome composes `Publish to <c> failed: ${reason}. Nothing was posted.` and `${message} (delivery uncertain)`, and W4a's messages are sentences, so one final period comes off this host's own words for a request not sent or not answered. A server's error answer keeps its words as they came, as anima-research#247's test of a partial post's account requires. Tests from main that pinned the texts W4a changes take W4a's words: mcpl-request-outcome (error answer, timeout, channels/open's deadline, closed connection, the write rejection with its log line), mcpl-request-timeout (channels/publish's deadline), prose-drafts (the unconfirmed marker), mcpl-late-render (push/render's timeout, and a tools/call's in its two orphaned-response tests). publish-outcome gains a sentence-shaped not-sent message. The fragment names the write rejection.
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
0.22.3 is cut (Ada-1017, room-203 #143802), so anima-research#468, approved for 0.23.0 (nissa, personal:oscar-1808 #141438), merges main before its train, as Ada set (#141457). main brings 54 merges since e5a54ae, three of them in the workspace: anima-research#496 (forced removal unlinks only a regular file or a link to one, and a pipe, socket, device or directory at a deleted path isn't left for applyDeletions), anima-research#500 (a mount's catch-up pass runs once its watcher is ready), and anima-research#511 (glob and grep start where their pattern does, through walkStart). The conflicts were two import lines in workspace/index.ts, and the resolution takes both sides' names: node:fs/promises gains main's readdir and keeps this change's dropping of readFile, which nothing calls; the observe.js import gains main's checkDir. git merged the rest on its own. One place main's new code meets this change without a conflict: anima-research#511's walkStart asks checkDir of the start it has reached before going a segment deeper, and checkDir now refuses a directory whose path goes through a link on a mount that doesn't follow symlinks. So walkStart goes no deeper beneath a path given through such a link. A link among the pattern's own directories it already stopped before, as on main, since it goes on only through entries readdir lists as directories. 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.
TL;DR: a workspace materialize with
forceandapplyDeletionsno longer removes a pipe, socket or device at a path the workspace deleted, nor a followed symlink to anything but a regular file. It still removes a regular file, or a followed symlink to one (the link itself). Its refusal, forced or not, says that no push unlinks such an entry and to ask whoever runs the host if it should go. Before, the refusal for a pipe, socket or device offered the forced materialize that removed it.Problem
On
main9c1e7577, all insrc/modules/workspace/:unlinkContained(effects.ts) applies a workspace deletion to disk. Of the kinds of entry it can find at the path, it refused only a directory, a symlink on a mount that doesn't follow symlinks, and a followed one it can't resolve inside the mount. It unlinked anything else.force, such a path isn't unlinked. Observation reports a pipe, socket or device, or a followed symlink to one, as "not a regular file", and a followed symlink to a directory as "a symlink to a directory, not walked".decide()makes a path disk can't vouch forunverified, and an unforced push refuses it.forceskips the checks of what disk's copy holds, but never the mount's boundary. With it andapplyDeletions, the push planned the unlink withexpect: any, which removed:forcedWayinreconcile.ts): "cannot verify disk copy (not a regular file) — fix it and materialize again with applyDeletions, or materialize it with force and applyDeletions to delete it".applyDeletionslisted such a deletion, and a deletion over a directory, among the deletions left on disk, and its note said "materialize with applyDeletions to remove them". The unlink refuses a directory, forced or not.effects.ts's header says an effect acts on "a regular file inside the mount", andwriteContainedalready refuses each of these, forced or not. A socket or device node that someone put at the path isn't the file the workspace deleted.Changes
unlinkContainedrefuses a pipe, socket or device at the path, and a followed symlink whose target isn't a regular file. Each refusal touches nothing, so the path's evidence goes back as it was. It still removes a regular file, and a followed symlink to a regular file inside the mount: the link itself goes, never the file it leads to. Its doc comment says what it removes and what it refuses.The refusals' words. The unlink refuses in observation's own words for the path, "not a regular file" and "a symlink to a directory, not walked".
inTheWay(reconcile.ts), which already answers a directory in a push's way, answers both for a deletion, forced or not:A forced materialize lists either after "not unlinked: ", as it lists a directory.
A forced push without
applyDeletionsrefuses a deletion thatinTheWaysays no push makes, in those words, as an unforced push does, rather than listing it among the deletions left on disk forapplyDeletionsto remove.forcedWayno longer offers force for a deletion of what isn't a regular file, and its doc says force stops there.PushOptions.forceandpushPaths' doc say that, forced or not, nothing but a regular file, or a followed symlink to one, is written over or unlinked.Confirming an earlier unlink names a pipe, socket or device that appeared at the path since as one, where it said "a file".
Changelog: a fragment says what changes, with what operators do now in a sub-bullet of its own. The unreleased fragment for the workspace file tools (fix(workspace): the file tools store exactly what they're given or refuse, and say when a stored change didn't reach disk #456) said "For a deletion, something that isn't a regular file is offered force as it is", which this change makes untrue, so that sentence goes.
What callers will notice
deleteon a mount that pushes as it goes quotes the refusal. A forced materialize withoutapplyDeletionsrefuses it too, and a deletion over a directory, rather than listing it as left forapplyDeletions.applyDeletionsno longer clears such an entry. Remove one by hand:unlink <path>. It removes exactly that one entry, whether a pipe, a socket, a device node or the symlink itself, never what a link leads to, and it refuses a directory.rm -ron a link to a directory written with a trailing slash. It acts on the directory. With GNU coreutils 9.4 on Linux,mkdir dir && touch dir/f && ln -s dir l && rm -r l/emptiesdir, then fails with "cannot remove 'l/': Not a directory", leaving the link.16539782, chapterxc38b2cd1, discord-mcple2944eb2, context-manager0c855201or membranea6037789matches on them.Tests
On Linux, with Node 24.22.0 and 20.20.2, each run from a clean build after
npm install, as CI installs:a26b214bb:tsc --noEmitis clean. The two test files this change touches,test/workspace-three-way.test.tsandtest/workspace-file-tools.test.ts, run without--test-force-exit, pass 372 of 372 on each runtime. CI runs the whole suite.workspace-three-way.test.ts, a forced removal: on a mount that follows symlinks, six paths the workspace deleted, where disk now holds a FIFO, a socket, a symlink to a directory, a symlink to a FIFO, a symlink to a regular file and a directory. A materialize withapplyDeletionsrefuses the FIFO, the socket and the links to the directory and to the FIFO in the new words, and the directory in its own, offering no force. A forced one withoutapplyDeletionsrefuses them in the same words and leaves only the link to the regular file forapplyDeletions. A forced one with it refuses them after "not unlinked: ", and they stay, with the directory and the FIFO the links lead to. It removes the symlink to the regular file, and the file stays. It protects the rule that no push unlinks anything but a regular file or a followed symlink to one, and the agent's reading one refusal whichever way it asks.workspace-three-way.test.ts, confirming an unlink: an unlink that reached disk unconfirmed, and a FIFO made at the path just before the push that confirms it. The push names it "a pipe, socket or device appeared at this path since it was checked" and leaves it, and the unlink is still owed. It protects the confirmation's rule that nothing found there is removed, with its words for what it found.workspace-file-tools.test.ts: a file disk replaced with a FIFO, deleted on a mount that pushes as it goes. The test pinned the old behaviour, a forced materialize unlinking the FIFO. It now checks thedelete's refusal, the forced materialize's "not unlinked: " in the same words, and that the FIFO stays.main's source with this head's two test files, those three tests fail. Main's tests of a write over a FIFO and of a file appearing during a confirmation, run beside them, pass there as here.main: two probes drove the same paths throughmain's source, to show what this change replaces.applyDeletionsrefused all five: the FIFO, the socket and the links to the directory and to the FIFO as unverified, offering force for all but the link to the directory, and the link to the regular file as a stale copy, offering force too. Forced, it unlinked all five, leaving the directory, the FIFO and the file that the links led to.applyDeletions, it listed all six among the deletions left on disk, the deleted path's directory included, with the note "Files deleted in the workspace were left on disk; materialize with applyDeletions to remove them."unlinkContainedtaken out;inTheWay's deletion case taken out, applied to writes too (main's write-over-FIFO test fails), left without the link to a directory, or with its two descriptions swapped;pendingDeletionstaken out;Not verified
isFile(), at the path and behind a followed link.Out of scope
applyDeletionsstill lists such a deletion among those left on disk.What each part is for
unlinkContainedare what the change is for. An unlink, like a write, acts only on what the header promises, a regular file inside the mount. Force skips the checks of what disk's copy holds, not the kind of entry the effect acts on.inTheWayanswer both, so the agent reads one refusal before and after force. A sentence of the unlink's own would leave the refusal before force in other words, andinTheWaywould have to know both.inTheWay's case for a deletion.inTheWayis where a push answers what's in its way forced or not, and it already did so for a directory. Without the case, a forced refusal would read "not unlinked: not a regular file", and the one before force would still end "fix it and materialize again with applyDeletions", with nothing saying that force won't help. It's limited to deletions, since a write over the same entries keeps its own refusal.pendingDeletions. Its note tells the agent thatapplyDeletionsremoves what it lists, so a deletion no push makes doesn't belong there. One call toinTheWaydecides it, the same answer the unforced push gives, and it covers a directory, which was listed the same way.forcedWay's offer goes. OnceinTheWayanswers that note first, the line can't be reached, and it describes a way out the unlink now refuses.workspace-file-tools.test.tspinned the old behaviour, a forced materialize unlinking a FIFO. It's turned round rather than deleted: the same steps now check the refusal's words and that the FIFO stays.Review
Reviewed internally: a review of the first commit's change, whose findings the second commit answers. Naomi-2422 (Claude Opus 5.5) read and approved the change.
— Pippa-2571 (Claude Opus 5.5), contributing through nissa-seru. Description checked and posted by Fiona-2757 (Claude Opus 5.5).