Skip to content

fix(workspace): forced removal unlinks only a regular file or a link to one - #496

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-forced-removal
Oct 18, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-forced-removal

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

TL;DR: a workspace materialize with force and applyDeletions no 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 main 9c1e7577, all in src/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.
  • Without 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 for unverified, and an unforced push refuses it.
  • force skips the checks of what disk's copy holds, but never the mount's boundary. With it and applyDeletions, the push planned the unlink with expect: any, which removed:
    • a pipe, socket or device at a path the workspace deleted;
    • on a mount that follows symlinks, a symlink whose target is a directory, a pipe, a socket or a device.
  • The refusal before any force named that forced materialize for a pipe, socket or device (forcedWay in reconcile.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".
  • A forced materialize without applyDeletions listed 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", and writeContained already 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

  • unlinkContained refuses 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:

    • "disk has a pipe, socket or device at this path, or a symlink to one, and no push unlinks it, forced or not — ask whoever runs this host if it should go"
    • "disk has 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"

    A forced materialize lists either after "not unlinked: ", as it lists a directory.

  • A forced push without applyDeletions refuses a deletion that inTheWay says no push makes, in those words, as an unforced push does, rather than listing it among the deletions left on disk for applyDeletions to remove.

  • forcedWay no longer offers force for a deletion of what isn't a regular file, and its doc says force stops there. 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.

  • 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

  • Agents: a deletion over a pipe, socket or device, or over a followed symlink to a directory or to one of those, is refused in the same words before and after force, and isn't offered force. A delete on a mount that pushes as it goes quotes the refusal. A forced materialize without applyDeletions refuses it too, and a deletion over a directory, rather than listing it as left for applyDeletions.
  • Operators: a forced materialize with applyDeletions no longer clears such an entry. Remove one by hand:
    • The safe way: 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.
    • To avoid: rm -r on 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/ empties dir, then fails with "cannot remove 'l/': Not a directory", leaving the link.
  • Scripts: these refusal texts changed. Nothing in connectome-host 16539782, chapterx c38b2cd1, discord-mcpl e2944eb2, context-manager 0c855201 or membrane a6037789 matches 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:

  • At this head, a26b214bb: tsc --noEmit is clean. The two test files this change touches, test/workspace-three-way.test.ts and test/workspace-file-tools.test.ts, run without --test-force-exit, pass 372 of 372 on each runtime. CI runs the whole suite.
  • The tests this change adds or changes:
    • 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 with applyDeletions refuses 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 without applyDeletions refuses them in the same words and leaves only the link to the regular file for applyDeletions. 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 the delete's refusal, the forced materialize's "not unlinked: " in the same words, and that the FIFO stays.
  • Red-first: against 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.
  • On main: two probes drove the same paths through main's source, to show what this change replaces.
    • The five paths other than the directory: before force, a materialize with applyDeletions refused 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.
    • All six: forced without 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."
  • Removals: each of twelve edits to the built code, made alone, fails at least one of those tests:
    • either refusal in unlinkContained taken out;
    • the check at the path narrowed to a FIFO, or the check behind a link narrowed to a directory;
    • the two link refusals swapped, or every followed link refused;
    • 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;
    • the check before pendingDeletions taken out;
    • the confirmation's label for a pipe, socket or device taken out.

Not verified

  • Windows: the tests that make a FIFO are skipped there, so neither refusal ran there. macOS until CI runs.
  • A device node: making one needs root, so the tests use a FIFO and a socket. A device meets the same check as they do, isFile(), at the path and behind a followed link.

Out of scope

  • A write over a pipe, socket or device, or a followed link to one, is refused forced or not, as before. Its refusal offers no force and keeps its wording: "cannot verify disk copy (not a regular file) — fix it and materialize again".
  • A followed symlink that dangles or loops is refused, forced or not, as "a symlink that leaves the mount", which it doesn't, and in other words than observation's. That wording is unchanged here.
  • A deletion over a symlink the mount doesn't follow, or over a followed one that dangles, loops or leaves the mount, is refused by the unlink forced or not, as before. A forced materialize without applyDeletions still lists such a deletion among those left on disk.
  • A path whose branch intent can't be read is refused before force for that, with "once it can be, sync this path", and left by a forced push, as before. Where disk holds a pipe, socket or device there, that refusal doesn't say that only someone who runs the host can clear it.
  • The wording of the push's other refusals is unchanged.

What each part is for

  • The two refusals in unlinkContained are 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.
  • Observation's words for the refusals, not a sentence of the unlink's own. The refusal before force comes from observation's note, and the forced one from the unlink. The same words let one case in inTheWay answer 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, and inTheWay would have to know both.
  • inTheWay's case for a deletion. inTheWay is 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.
  • "Ask whoever runs this host if it should go", where the directory case says "move or remove it on disk". A socket or device node at the path is likely the host's, and removing one can do real harm, so whether it goes is for whoever runs the host to decide. The changelog says how they remove one safely.
  • The check before pendingDeletions. Its note tells the agent that applyDeletions removes what it lists, so a deletion no push makes doesn't belong there. One call to inTheWay decides it, the same answer the unforced push gives, and it covers a directory, which was listed the same way.
  • forcedWay's offer goes. Once inTheWay answers that note first, the line can't be reached, and it describes a way out the unlink now refuses.
  • The confirmation's label costs one more case in an expression, and makes the function name a pipe, socket or device the same way wherever it meets one.
  • The doc lines say where force stops, since the docs said it overwrites or unlinks a copy that can't be verified.
  • The changed test in workspace-file-tools.test.ts pinned 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).

Pippa-2571 and others added 2 commits October 18, 2026 04:59
…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>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit c5d857b 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
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant