Skip to content

fix(workspace): answer a refused or failed call with what didn't happen and the way on - #822

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-answers
Oct 27, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:fix/workspace-answers

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This pull request rewrites the answers the workspace's tools give to a call they refuse, or one that fails, apart from those listed under "Not changed here". Each now says what happened. Where there is something to do, it then gives the way on, or says that calling again won't change it and who can.

Problem

Measured on main (a4e99e48) by real tool calls:

  • An error a handler didn't expect reached the agent as its bare words. With the store's write throwing ENOSPC, write and edit answered ENOSPC: no space left on device: no call, nothing on whether anything changed, nothing to do next. Node's message for a failed system call can name the path the call was given.
  • write of a mount's root ("work") answered the store's own Invalid operation: Invalid tree path: ''.
  • read or delete of a mount's root answered work is not in the workspace, and disk could not be checked (a parent directory resolves outside the mount). No parent resolves outside. A directory got … disk could not be checked (a directory)., though disk was checked and holds one.
  • Most refusals said why but not what didn't happen or what to do: Unknown mount: "Work". Available: work, docs, … (no closest name; for /work/a.txt, Unknown mount: ""), Unknown mount: Docs for a mount field, Mount "docs" is read-only, Content exceeds max file size (16 bytes), File not found: work/nope.md, Generated file belongs to another agent, and read_image's Invalid PNG image: …, Unsupported image format: …, Image file exceeds max size (16 bytes): …, Unable to read image file: … (the errno dropped).
  • Reasons built from an errno gave the code itself: cannot open (EACCES), cannot make the staged file in its directory (EACCES).
  • A forced materialize onto a symlink that leads nowhere, on a mount that follows links, said not written: something appeared at this path since it was checked. Nothing had appeared.
  • While the disk-agreement evidence awaits reopening, read_image of an image whose stored contents are lost answered … Write the file again, or delete it., and the write and the delete were then refused, in words that didn't say the call did nothing or who ends that state.
  • materialize held back by a diverged branch said to pass force: true "if it is canonical", with nothing to judge that by.
  • ls of a missing directory, or of a file, and materialize or sync of a path the workspace never held, answered empty, as an empty directory does.

Changes

  • One form (answers.ts). A Refusal is an error whose message is written for the agent: why the call did nothing, then the way on. didNothing leads it with what didn't happen, The <tool> did nothing: , the form main's newer answers already use. onlyTheHost says that calling again won't change it and who can: Calling again won't change that; whoever runs this host can …. errnoWords gives an errno in plain words, and errnoWayOn what follows from it: who can fix a disk or host fault, whether a second call may get past it, or that a second call meets it again. errnoCode reads an error's errno from its code, or, for a store's I/O failure, from the OS error number its message ends with. unexpectedAnswer frames an error no handler threw on purpose.
  • The catch in runToolCall leads a thrown Refusal with the call's name, answers a WorkspaceReadError by its code (readRefusal), and frames anything else with the call and its path. For a write, edit, edit_many or delete that failed before its change was stored, the answer says so; where every change was one its files already held, it says the workspace holds it either way; where whether anything changed isn't known, it says how to look (read the file, or ls of the mount with recursive: true for materialize and sync). Where the call left the store awaiting its reopening, the answer says only a restart ends that. So does the answer to a change stored before its push left the store so. Where a write, edit, edit_many, delete, materialize or sync met the evidence refusal after one of its own evidence reads had passed, the answer says the call stopped partway, since the refusal may then come after whatever the call had done. The whole error goes to the host's log.
  • The parse refusals (a path naming no mount, a separator right after a mount's name, a path that climbs out of its mount, a lone surrogate) are Refusals with a way on: a path naming no mount lists the mounts and offers the closest name, and the others say how to give the path. readFileFromDisk takes their code from a field rather than from the message's first words, and throws an error that isn't a refusal as it is.
  • readFileFromDisk's WorkspaceReadError messages for what it finds on disk keep their words, since a host quotes them and adds its own remedy by code. The tools translate them by code (readRefusal). RootUnavailable, a WorkspaceReadError, carries a root refusal's reason as a field, which the translation and deleteDirect read instead of the message. On a mount the workspace stores, for a file neither the workspace nor disk has, readBinary uses readFileFromDisk's words, File not found: <path>.
  • read_image: an image's own problems (empty, too_large, truncated, invalid, unsupported) are Refusals with a reason and a way on. A disk failure stays a WorkspaceReadError for readRefusal, so the mapping that matched on message text goes.
  • Answers, each through those helpers:
    • a path naming a mount's root, given to write, read, read_image, delete or writeBinary, is answered as not a file, in editablePath's words; a path naming a directory is answered as a directory, with ls to list it;
    • a read-only mount says calling again won't change it, who can, and which mounts take writes;
    • a size refusal gives the size and the limit, in edit's words for the same limit (pastSizeLimit);
    • a generated file says whose it is, or that set_tool_visibility and set_tool_description change it;
    • for a missing file, a file disk holds that the workspace doesn't store, and a file disk couldn't be checked for, the answer says what reads it or who can change it; a file disk holds that the workspace doesn't store offers read_image only for an image within the mount's size limit;
    • a direct mount's write refusals carry their way on (WriteRefused.wayOn), a directory the write can't make, a file where a directory should be and a landing outside the mount included, and an edit_many that wrote some of its files says who can fix the failure;
    • grep's invalid pattern says how to match a character as itself, and its skip of a conflict whose disk version was never stored says how to settle it, in read's words for a conflict (settleConflict), so a read-only mount isn't offered a materialize;
    • the offset that splits a surrogate pair gives the two that work;
    • a mount field naming no mount says what leaving it out covers for that tool;
    • sync with both acceptRoot and path names the field acceptRoot takes, and the mount where the path names one;
    • an unknown tool lists the module's tools (unknownToolMessage), whatever path it names;
    • the wake notice's conflicts line says reading each shows how to settle it.
  • Errnos in plain words. observe.ts's notes, a directory a write can't make included, direct-write.ts's refusals, a direct delete's root refusal and the unexpected-error frame give an errno in plain words. An error with no code keeps its own words, cut at its bound, as before. reconcile.ts's forcedWay matches the new note.
  • False texts: a forced push onto a followed symlink that leads nowhere says so, and who can remove the link (effects.ts); a link whose target can't be checked keeps the old reason. While the evidence awaits reopening, the refusal says a restart reopens the store (disk-agreement.ts), and a lost file's answer offers its write or delete for after that (LostContents.reopen, from DiskAgreement.awaitsReopen). A diverged branch names ls of the mount with recursive: true as what shows how each file stands against disk.
  • Notes: ls of a missing path or of a file, and materialize or sync of a path held nowhere, add a note saying so, naming the path as disk spells it. Disk decides first: a directory there is listed as one, whatever the store's history holds at the path.

Not changed here:

  • The reasons a push gives (materialize's and sync's skipped, the push reason in a write or delete that was stored but whose push didn't complete) and the walk's cannot list keep their words, errno codes included, such as cannot open the file (ELOOP), apart from where one quotes a note of observe.ts's, whose errnos are now plain words, such as cannot verify disk copy (cannot open (permission denied)).
  • A direct delete's unlink failure, which shares the push's unlink reason and quotes the system's message.
  • readFileFromDisk's reasons for a symlink among a path's directories (linkWords). The tools pass them on whole after the lead, so where one names the setting the mount's host can change, the answer does too.
  • edit's and edit_many's words for an edit whose old text doesn't fit the file, such as one not found or found a different number of times than asked (edit-text.ts), which already said what didn't happen and what to do.
  • readBinaryFromDisk's and a direct mount's readBinary's words for a file over the size limit or missing, File too large: <size> > <limit> bytes and File not found on disk: <path>, which their tests pin as their words for hosts.

What each part is for

  • Refusal marks an error whose message was written for the agent, so the catch can lead it rather than frame it as unexpected. OutsideIncludeError, NestedMountError, PathRefusal, the brace refusals, EvidenceUnavailable and WorkspaceImageReadError are Refusals for that reason.
  • EvidenceUnavailable is the evidence refusal's own class, so the catch can tell it from a refusal thrown before the call did anything. callGate marks, per call, whether one of the call's own evidence reads passed ensureReconciled. A refusal at the call's first evidence read comes before any effect. Another call can make the store await its reopening while this one waits on its parse, so the store's state alone can't tell whether one of the call's own evidence reads passed. reopenAtStart says whether the store already awaited reopening as the call began, for the unexpected-error frame's reopen sentence.
  • systemWords gives an errno in plain words and an error with no code in its own words, cut at its bound, at every disk check that quoted code ?? String(err) but the walk's cannot list, which keeps its words.
  • NotRegularFile marks the refusal of a pipe, a socket or a device, so readRefusal doesn't call a refusal with no errno a pipe, a socket or a device. Hosts see the same WorkspaceReadError, with the same code, stage and message.
  • PathRefusal's code gives readFileFromDisk the code it took from message.startsWith('Unknown mount').
  • didNothing's to keeps the direct mounts' form, The write did nothing to "<path>": <reason>.
  • onlyTheHost, errnoWords, hostFix and errnoWayOn give the words every refusal and failure share for who can fix it and what an errno means. PASSING holds the faults a second call may get past (EIO, EMFILE, ENFILE, ENOMEM), and AS_DISK_IS those that follow from what disk holds, so a second call meets them again. errnoWayOn's hostsName says a name too long is the mount's path, the host's to shorten, at the root sites (readRefusal, the direct delete's and write's root checks). hostFix gives EFBIG its own fix, a file-size limit or another filesystem, and errnoWords names EWOULDBLOCK with EAGAIN.
  • errnoCode tells a Node system error, whose message can name the path the call was given, from an error whose words can be quoted. OS_ERRNO maps a store error's OS error number to its name on this host, the first name Node lists for a number. Chronicle 0.4.0's errors carry the code GenericFailure and a message such as IO error: Permission denied (os error 13) (a run on Linux). Only an IO error: message is read so, Chronicle 0.4.0's one form for an I/O failure, and not on Windows, where the number is a Win32 code; there the tests that read one skip, or expect the store's message.
  • changesFiles names the calls whose failure can leave a change behind, for which the frame says whether anything changed.
  • unexpectedAnswer and its Unexpected options word the frame for each way the call ended: before its change was stored, with every change already held, unknown, or with the store left awaiting its reopening (AWAITS_REOPEN, which storedButUnfinished's and editsStoredButUnfinished's reopen, and writeBinary's stored failure, add too).
  • failureAnswer chooses the answer for whatever a handler threw.
  • NotStored carries an error that came before a write, edit, edit_many or delete stored its change, path parsing included, so the frame can say nothing was stored. AlreadyHeld carries a store's failure in a change whose every path already held what it gave. CommitUnknownError keeps its two causes, framed as unknown in storeWords: an errno in plain words, else the store's message cut as a first line is.
  • RootUnavailable's reason and refusal carry a root refusal's reason and the evidence refusal through readRoot, so a tool answers in their words.
  • readRefusal translates readFileFromDisk's refusals for the tools by code and stage; unreadable words its disk failures.
  • WriteRefused.wayOn carries each direct refusal's way on from where it's known, and WriteRefused.errno its errno, so a partway edit_many can say who fixes it without inviting a second call; refusedWayOn gives the way on for a directory, root or landing reason, from the errno DirCheck and checkRoot now carry where a system call failed. NOT_A_DIRECTORY names the reason a write meets where a file stands in place of a directory, and its way on says another path, or removing what stands there, would work. NAME_ITS_OWN, AFRESH, DIRECTORY_THERE and NOT_REGULAR_THERE are ways on two or more refusals give. directWayOn and directDidNothing put a direct refusal's reason and way on after the lead.
  • DIRECTORY, NOT_REGULAR and LEAVES_MOUNT in observe.ts name notes that uncheckedFile and refusedWayOn choose a way on by, as NOT_FOLLOWED and the root notes already were.
  • noSuchMount, noSuchMountField and mountList word the path's and the mount field's refusals. writableNames lists the mounts that take writes, for writeRefusal.
  • namesRoot, tooLargeToWrite, pastSizeLimit, deletedOnDisk, uncheckedFile, sentence, settleConflict, generatedFileRefusal, lostText and remedyOfLost each word one answer that two or more sites give.
  • truncatedImage, emptyImage, imageTooLarge and IMAGE_STAYS word read_image's refusals of a file's bytes.
  • notInWorkspace's missing gives readBinary readFileFromDisk's words for a file neither has, which connectome-host compares with readFileFromDisk's to give a reason both share once.
  • emptyScopeNote, holdsAnythingAt and absentOnDisk decide the notes. They take a path as a file where disk holds a file there, or, where disk has nothing, where the store holds one. A path is held nowhere when neither the store, its intents nor disk has anything at it.
  • ROOT_UNCHECKED is the way on in an unchecked file's answer, which has only the pass's note. That note can't tell a directory gone from its path from a path that came to name another directory during the call, so the words cover both: calling again won't change the answer while the mount's directory is gone from its path, and otherwise checks the directory afresh.
  • ROOT_RECHECK says calling again may work, and if it fails the same way, whoever runs this host can check the mount's directory and its path. In a read, a direct write and a direct delete, it's the way on for a root errno that has no way on of its own, such as ESTALE, ETIMEDOUT and EINVAL. It's also the way on for a read whose root check gave no errno while a directory is at the path, and for a direct write whose root check finds a directory at the root the call resolved, as when that path came to name another directory during the call.
  • rootWayOn (direct-write.ts) gives each refusal of a direct write's root check one way on. ENOENT and ENOTDIR say to put the directory back. Any other errno gets its own way on where it has one, such as changing the permissions for EACCES, or, for EISDIR, that calling again won't change it while disk holds what it does there. An errno with no way on of its own gets ROOT_RECHECK.
  • rootAbsence moves from index.ts to observe.ts, so a direct write's root check uses it as readRoot does. Where the check gave no errno, what is at the root's path decides: nothing gives ENOENT, something other than a directory gives ENOTDIR, and a directory gives none. So a direct write whose mount root is now a file says to put the directory back, as a read does.
  • readRoot reads the evidence before it resolves the root. With no whole evidence, as when a host kept the module after initStore's open threw, every read is refused until the store is reopened, so a read answers with that refusal, not a root fault.
  • A read whose root's path came to name another directory during the call says calling again may work. A directory other than the one the workspace recorded for the mount gets ROOT_REPLACED's words, which say to reconnect it, or to sync the mount with acceptRoot if it was replaced on purpose (observe.ts:156–158).
  • ONCE_REOPENED tells a stored change's answer, where the store awaits its reopening, that the materialize it names writes the change only once the store is reopened.
  • The generated-file check runs only for a tool the module has (schema !== undefined), so an unknown tool naming a generated file gets the module's tool list.
  • awaitsReopen tells a lost file's answer that the remedies it would offer are refused until the store is reopened, and the catch whether the store awaits its reopening.
  • The changelog fragment says what changed for agents and for hosts.
  • test/workspace-path-spelling.test.ts leads fix(workspace): compare a path with a nested mount's directory as disk compares names, level by level #624's nested-mount refusal assertions with "The did nothing:", and ends a link's reason with a period, as the answers now do.
  • test/workspace-answers.test.ts pins the answers this change adds, each with the state that reaches it; its storeError throws a store error as Chronicle 0.4.0 does, and MAY_WORK and REOPEN are frames several tests assert. workspace-file-tools.test.ts's unstored, MAY_WORK and FULL_DISK word the frame its store failures meet.

For hosts

A refusal's code is unchanged. The words have changed for a path that names no mount, has a separator right after a mount's name, climbs out of its mount or holds a lone surrogate; for the read-only and size refusals (writeBinary); for readBinary's answers, but for a file neither the workspace nor disk has; for an errno readFileFromDisk gives from a link check, now in plain words; and for the reason after Mount unavailable: <mount>: while the store awaits reopening. readBinaryFromDisk takes the new parse refusals' words. Where the store holds no whole evidence, as when a host kept the module after initStore's open threw, and the mount's directory is gone, readFileFromDisk's error has the stage root, where main gave realpath_root; its code, mount_unavailable, and its errno, ENOENT, are as before, and its message, Mount unavailable: <mount> on main, now gives the reason it gives when the directory is there. When parsing a path meets an error that isn't a refusal, such as the store's, readFileFromDisk throws it as it is rather than as a traversal refusal.

In connectome-host at 3e4cf9b3, the identity module picks a refused upload's remedy from a WorkspaceReadError's code and errno (diskHasNoFile and diskRemedy, src/modules/identity-module.ts:66, :76). Where readFileFromDisk's message equals readBinary's error, the module gives that reason once (:1082–1084). Some of its tests assert words this change alters, such as Unknown mount: "nope". Available: files. (test/identity-and-surfaces.test.ts:1825). We didn't run connectome-host against this head.

Tests

Requirements, each with the test that pins it, in test/workspace-answers.test.ts unless named:

  • An error no handler threw on purpose names the call and its path ("an error no handler threw on purpose"). For such an error:
    • Before a change is stored, the answer says so and gives a store's disk fault in plain words for whoever runs the host, and nothing is stored. This holds for an error shaped as Chronicle's and for a real one from store directories nothing may write (skipped as root and on Windows).
    • An errno a second call may get past says so.
    • An error with no errno is quoted, cut at its bound.
    • A call that may have changed files says that isn't known and how to look. A call that changes nothing doesn't say so.
    • An error before a write, edit or delete reaches its change says nothing was stored, and nothing is. readFileFromDisk throws such an error as it is.
    • A store error that isn't an I/O failure keeps its words.
    • An append that fails inside the store says only a restart ends the state it leaves, and later calls say they did nothing. So does a write, edit, edit_many, delete or writeBinary stored before its push's append fails.
    • The store coming to await reopening partway through a materialize says the call stopped partway. The store coming to await it through another call's failed append, while a write parses its path, says the write did nothing ("…before the call reaches its change, says the call did nothing").
  • An error with no code is quoted, cut ("a system error with no code"), and a read that fails without one isn't called a pipe, a socket or a device.
  • Tests in workspace-edits.test.ts pin edit_many's store failures, and one in workspace-file-tools.test.ts pins a write of bytes the file already held.
  • A mount's root (writeBinary's too), an unknown tool (one naming a generated file too), an unknown mount (closest name, leading /, a bare name), an unknown mount field (per tool), a path that climbs out, a directory, a missing file, a read-only mount (and writeBinary's), content over the size limit (and writeBinary's), a generated file, read_image's image problems, symlinks it doesn't follow or that lead out, a file disk refuses to read, a mount whose directory is gone, a file disk holds that the workspace doesn't store (an image within the limit, which read_image then shows, and one past it), a followed symlink that leads nowhere or out, a direct mount's write and edit refusals, its staged-file and new-directory errnos, a file where a directory should be and a name too long, readBinary's missing file, read paging, grep's pattern and acceptRoot with a path (and one with a leading /): each has a test that makes the state and asserts the whole answer, but the unknown tool, whose test asserts the list's start, one tool in it and its end.
  • The tests under "what the answers say elsewhere" pin grep's skip of a conflict whose disk version was never stored, on a read-write and a read-only mount, the wake notice's conflicts line, and the ls, materialize and sync notes.
  • The tests under "false texts" pin the forced push onto a link that leads nowhere, and the diverged branch, whose test also lists the files with ls to show what it names.
  • "read_image tells a loss while the disk-agreement evidence awaits reopening…", in workspace-lost-contents.test.ts, pins the evidence state's refusal, and the lost image's answer in that state with the write and the delete it offers for after.
  • Three tests in workspace-answers.test.ts pin the root answers. The test "a mount directory a read, a write or a delete can't check …" pins a read and a direct write whose mount's path comes to name another directory during the call, where calling again then works, and ESTALE from a read's and a direct write's root realpath and from a direct delete's root stat. The test "a mount whose directory is gone, or is now a file …" pins a mount root that is now a file, for a direct write and a read. The test "a disk read while the store holds no whole record …" pins a read after initStore's open threw, and readFileFromDisk's error then, with the mount's directory gone: code mount_unavailable, stage root, errno ENOENT. repointedDuring repoints the path at the realpath readRoot or diskView makes.
  • The existing tests that pinned the old words now pin the new ones, those that run only where disk folds case among them (workspace-path-spelling.test.ts), which this change's runs, on Linux, skip.

Runs, all on Linux:

  • npm test at this head, Node 24.22.1: 8,217 tests, 8,155 pass, 0 fail, 62 skipped. The 62 are the tests that run only where disk folds case, or folds a letter or a normalization form this filesystem keeps apart.
  • The changed test files, on Node 24.22.1 and 22.12.0: 1,075 tests on each, 1,013 pass, 0 fail, 62 skipped, the same 62.
  • On main's code at 3520ad49 with this head's test/workspace-answers.test.ts, 44 of its 45 tests fail, each at main's answer, with stand-ins for what the file imports and main lacks: a systemWords that gives main's code ?? String(err), and a callGate that nothing reads. The one that passes, readBinary's missing file, pins words main already gives, which this change keeps.
  • Removals, at 2204e73a, which this head differs from only in one test: each of 35 pieces, undone or weakened on its own, fails the test aimed at it in workspace-answers, workspace-file-tools, workspace-edits or workspace-direct-write: the partway answer, the reopen frame, a store error's errno, EIO's way on, AlreadyHeld, the parse failure before a write's change, readFileFromDisk's rethrow, readBinary's missing file, read_image's size check, grep's read-only remedy, the way on for a landing outside the mount, the way on for a directory check's errno, the mount field's clause, acceptRoot's mount, a long name's way on, the reopen sentence after a stored change, the generated-file check's tool, the IO error: form, a file where a directory should be, the partway edit_many's fixer, the way on for an unchecked file under a root that can't be checked, writeBinary's reopen sentence, the call's own gate for "stopped partway", the non-regular file's marker, quoting an error with no code, writeBinary's root refusal, the materialize a stored change names once the store is reopened, edit_many's frame, the evidence read before the root, a direct write's root refusal with no errno, a read's root errno with no fix of its own, a direct write's root check taking no errno from what is at its path, a direct write's unresolvable root getting a path's way on, a direct delete's root errno with no fix of its own, and a direct write's root errno with a fix of its own getting ROOT_RECHECK.

Other open pull requests

We merged 2204e73a, which this head differs from only in one test, with main at 98340a21 (Oct 26), then merged each of the 42 open pull requests into that merge and into main. None conflicts with the merge while merging cleanly with main. #271 already conflicts with main, and this head changes the hunks of its conflict in index.ts. This head merges cleanly with main at 98340a21, and carries main at 3520ad49.

Not verified

  • Every run of ours is on Linux, with Node 24.22.1, and 22.12.0 for the changed test files. On macOS, CI at 2204e73a, which this head differs from only in one test, ran the tests that run only where disk folds case, but one, and they passed. That one, for a disk that folds case and keeps ß apart from ss (workspace-path-spelling.test.ts:1005), runs on neither Linux nor macOS. On Windows a store error's OS error isn't read (by reading Rust's io::Error, whose number there is a Win32 code).
  • A store error for a full disk. A test makes a real store error, IO error: Permission denied (os error 13), from store directories nothing may write. For a full disk, the tests throw a store error ending (os error 28), ENOSPC's number. That a full disk gives that is a reading of Rust's std::io::Error format; no run made one.
  • A name too long in the mount's own path; a followed link whose target fails stat with an errno other than ENOENT or ENOTDIR once the observation found nothing there; and a store mount whose root changes within a pass, the second state ROOT_UNCHECKED's words cover. No test makes these states.
  • A direct write checks the root its call resolved at its start. If the configured path is repointed and that directory removed between the call's start and its check, the write's answer, new in this change, says to put the directory back, where a call made then would succeed. This change doesn't change when the root is checked.

What this leaves

Who did what

Pippa-2571 (Claude Opus 5.5) built the change. Ada-1017 (Claude Opus 5.5) made the design calls. She decided which answers fall short of saying what didn't happen and what to do instead, and which of those to leave out of this change. She chose to word every refusal and failure through the same helpers, to reuse editablePath's, unknownToolMessage's, closestNames' and edit's size answer, to give errnos in plain words with no host paths, and to leave tool descriptions unchanged. Four internal reviews read the first build, and six more each read a round after it. This head answers their findings and the automated review on this pull request. Naomi-2422 (Claude Opus 5.5), who built none of it, read it and approved this head, its merge of main included.

Each answer before and after, by run

Each call ran against a real workspace on main at 3520ad49 and on 2204e73a, which this head differs from only in one test, on Node 24.22.1 on Linux 6.12; the after texts are 2204e73a's. Each answer is quoted on one line, with ⏎, and a space on each side of it, where the message breaks its line.

  • read {"path":"notes/todo.md"}
    • before: Unknown mount: "notes". Available: work, docs, auto, small, inc, watched
    • after: The read did nothing: "notes/todo.md" starts with "notes", which is no mount's name, and a path starts with its mount's name; the mounts are "work", "docs", "auto", "small", "inc" and "watched".
  • read {"path":"Work/todo.md"}
    • before: Unknown mount: "Work". Available: work, docs, auto, small, inc, watched
    • after: The read did nothing: "Work/todo.md" starts with "Work", which is no mount's name, and a path starts with its mount's name; the mounts are "work", "docs", "auto", "small", "inc" and "watched". Did you mean "work/todo.md"?
  • read {"path":"/work/todo.md"}
    • before: Unknown mount: "". Available: work, docs, auto, small, inc, watched
    • after: The read did nothing: "/work/todo.md" starts with "/", and a path starts with its mount's name: give "work/todo.md".
  • read {"path":"work/../secrets.txt"}
    • before: Path traversal detected: "work/../secrets.txt" climbs above the root of mount "work".
    • after: The read did nothing: "work/../secrets.txt" climbs above the root of mount "work" with "..", and a path can't leave its mount. Name a file from its own mount's name; the mounts are "work", "docs", "auto", "small", "inc" and "watched".
  • read {"path":"work//etc/hosts"}
    • before: Path traversal detected: "work//etc/hosts" has a separator right after mount "work", which makes it an absolute path: give it from the mount's root.
    • after: The read did nothing: "work//etc/hosts" has a separator right after mount "work", which makes it an absolute path: give it from the mount's root, as "work/<path>".
  • read {"path":"work/\ud800.txt"}
    • before: The path "work/\ud800.txt" holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Nothing was done.
    • after: The read did nothing: "work/\ud800.txt" holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.
  • reed {"path":"work/a.txt"}
    • before: Unknown tool: reed, so nothing was done
    • after: There's no tool "workspace--reed", so nothing ran. The workspace module's tools are workspace--read, workspace--read_image, workspace--write, workspace--edit, workspace--edit_many, workspace--delete, workspace--ls, workspace--glob, workspace--grep, workspace--status, workspace--materialize and workspace--sync.
  • read {"path":"inc/a.txt"}
    • before: "inc/a.txt" is outside the mount's include: mount "inc" holds only *.md. The host's operator sets the include in the mount's configuration, so calling again with this path changes nothing.
    • after: The read did nothing: "inc/a.txt" is outside the mount's include: mount "inc" holds only *.md. The host's operator sets the include in the mount's configuration, so calling again with this path changes nothing.
  • read {"path":"work/nope.md"}
    • before: File not found: work/nope.md
    • after: The read did nothing: there's no file at "work/nope.md". ls or glob lists the files that exist.
  • read {"path":"work/nope.md","offset":1,"offsetChars":0}
    • before: Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.
    • after: The read did nothing: it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.
  • read {"path":"work/a.txt","offsetChars":13}
    • before: offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.
    • after: The read did nothing: "offsetChars" 13 falls between the two halves of a surrogate pair, one character in UTF-16. Give 12 or 14; a page's nextOffsetChars never splits one.
  • read {"path":"work"}
    • before: work is not in the workspace, and disk could not be checked (a parent directory resolves outside the mount).
    • after: The read did nothing: "work" names the root of mount "work", which isn't a file; ls "work" lists the files in it.
  • read {"path":"work/pics"}
    • before: work/pics is not in the workspace, and disk could not be checked (a directory).
    • after: The read did nothing: "work/pics" is a directory, not a file; ls "work/pics" lists what's in it.
  • write {"path":"docs/a.txt","content":"x"}
    • before: Mount "docs" is read-only
    • after: The write did nothing: mount "docs" is read-only. Calling again won't change that; whoever runs this host can make it read-write. The mounts that take writes are "work", "auto", "small", "inc" and "watched".
  • write {"path":"small/a.txt","content":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
    • before: Content exceeds max file size (16 bytes)
    • after: The write did nothing: "small/a.txt" would be 40 bytes, past the 16-byte limit this mount sets on a file, so the workspace couldn't keep it. Content within the limit is written. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.
  • write {"path":"work/s.txt","content":"a\ud800b"}
    • before: The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can't store.
    • after: The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.
  • write {"path":"work","content":"x"}
    • before: Invalid operation: Invalid tree path: ''
    • after: The write did nothing: "work" names the root of mount "work", which isn't a file; ls "work" lists the files in it.
  • delete {"path":"work/c.txt"}
    • before: work/c.txt was deleted in the workspace; disk still has a copy. Sync this path to restore it.
    • after: The delete did nothing: "work/c.txt" was deleted in the workspace; disk still has a copy. Sync this path to restore it.
  • read_image {"path":"work/sig.png"}
    • before: Invalid PNG image: work/sig.png
    • after: The read_image did nothing: "work/sig.png" starts as a PNG image, but its data doesn't hold together as one. Calling again won't change that; only a whole image in its place would.
  • read_image {"path":"work/short.png"}
    • before: Truncated image signature: work/short.png
    • after: The read_image did nothing: "work/short.png" is too short to be an image: it ends inside an image format's signature. Calling again won't change that; only a whole image in its place would.
  • read_image {"path":"work/notes.bmp"}
    • before: Unsupported image format: work/notes.bmp
    • after: The read_image did nothing: "work/notes.bmp" isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows. read shows it, if it's text.
  • read_image {"path":"work/empty.png"}
    • before: Image file is empty: work/empty.png
    • after: The read_image did nothing: "work/empty.png" is empty, so there's no image in it. Calling again won't change that; only a whole image in its place would.
  • read_image {"path":"small/big.png"}
    • before: Image file exceeds max size (16 bytes): small/big.png
    • after: The read_image did nothing: "small/big.png" is 40 bytes, past the 16-byte limit this mount sets on a file, so read_image doesn't show it. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.
  • read_image {"path":"work/pics"}
    • before: Path is a directory: work/pics
    • after: The read_image did nothing: "work/pics" is a directory, not a file; ls "work/pics" lists what's in it.
  • read_image {"path":"work/link.png"}
    • before: Symlinks are not allowed: work/link.png
    • after: The read_image did nothing: "work/link.png" is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. Calling again won't change that; if you know the file it leads to, name that file by its own path.
  • read_image {"path":"work/locked.png"}
    • before: Unable to read image file: work/locked.png
    • after: The read_image did nothing: "work/locked.png" can't be read from disk: permission denied. Calling again won't change that; whoever runs this host can change the permissions.
  • read {"path":"work/locked.png"}
    • before: work/locked.png is not in the workspace, and disk could not be checked (cannot open (EACCES)).
    • after: The read did nothing: "work/locked.png" isn't in the workspace, and disk couldn't be checked: cannot open (permission denied). Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.
  • read {"path":"work/shot.png"}
    • before: work/shot.png is on disk but not stored in the workspace (binary or over the size limit); read it with read_image.
    • after: The read did nothing: "work/shot.png" is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. read, edit and delete act only on the files it stores, so calling again won't change that; read_image shows it, if its data holds together as an image.
  • read {"path":"work/data.bin"}
    • before: work/data.bin is on disk but not stored in the workspace (binary or over the size limit).
    • after: The read did nothing: "work/data.bin" is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. read, edit and delete act only on the files it stores, so calling again won't change that.
  • grep {"pattern":"foo("}
    • before: The grep did nothing: Invalid regular expression: /foo(/: Unterminated group.
    • after: The grep did nothing: "pattern" "foo(" isn't a valid regular expression (Unterminated group). To match a character such as "(", "[" or "*" as itself, put "\" before it.
  • glob {"pattern":"work/{a,b"}
    • before: glob pattern "work/{a,b" has a "{" with no closing "}". To match a "{" in a name, put "?" in its place.
    • after: The glob did nothing: glob pattern "work/{a,b" has a "{" with no closing "}". To match a "{" in a name, put "?" in its place.
  • ls {"path":"nowhere"}
    • before: Unknown mount: "nowhere". Available: work, docs, auto, small, inc, watched
    • after: The ls did nothing: "nowhere" is no mount's name; the mounts are "work", "docs", "auto", "small", "inc" and "watched".
  • ls {"path":"work/nowhere"}
    • before: (success) {"path":"work/nowhere","entries":[],"count":0}
    • after: (success) {"path":"work/nowhere","entries":[],"count":0,"note":"\"work/nowhere\" isn't a directory in the workspace or on disk, so there's nothing to list."}
  • status {"mount":"Docs"}
    • before: Unknown mount: Docs
    • after: The status did nothing: "mount" is "Docs", which is no mount's name; the mounts are "work", "docs", "auto", "small", "inc" and "watched". Did you mean "docs"? Leaving "mount" out covers every mount.
  • materialize {"path":"work/never.txt"}
    • before: (success) {"materialized":[],"count":0}
    • after: (success) {"materialized":[],"count":0,"note":"The workspace holds nothing at \"work/never.txt\", so there was nothing to write there."}
  • sync {"path":"work/a.txt","acceptRoot":true}
    • before: acceptRoot takes a whole mount's root: give mount, or nothing, not path
    • after: The sync did nothing: acceptRoot takes a whole mount's root, so it isn't given with "path" ("work/a.txt"). Give "mount" ("work") with acceptRoot to take that mount's root, or leave acceptRoot out to sync the path.
  • sync {"path":"docs/none.txt"}
    • before: (success) {"results":[],"totalSynced":0,"totalConflicts":0}
    • after: (success) {"results":[],"note":"Neither the workspace nor disk has anything at \"docs/none.txt\", so there was nothing to sync.","totalSynced":0,"totalConflicts":0}
  • a workspace:modified event with a conflict
    • before: [workspace event · modified · auto/a.txt] ⏎ [conflicts: auto/a.txt]
    • after: [workspace event · modified · auto/a.txt] ⏎ [conflicts with disk: auto/a.txt; reading each shows how to settle it]
  • writeBinary("docs/x.png", …)
    • before: Mount "docs" is read-only
    • after: mount "docs" is read-only. Calling again won't change that; whoever runs this host can make it read-write. The mounts that take writes are "work", "auto", "small", "inc" and "watched".
  • writeBinary("small/x.png", 40 bytes)
    • before: Content exceeds max file size (16 bytes)
    • after: "small/x.png" would be 40 bytes, past the 16-byte limit this mount sets on a file, so the workspace couldn't keep it. Content within the limit is written. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.
  • grep {"pattern":"two","path":"work/bin.txt"}
    • before: (success) pattern: two ⏎ totalMatches: 1 ⏎ --- work/bin.txt: workspace (conflict) --- ⏎ 1:two ⏎ --- skipped --- ⏎ work/bin.txt: conflict: the disk version is binary or over the size limit, never stored (5 bytes)
    • after: (success) pattern: two ⏎ totalMatches: 1 ⏎ --- work/bin.txt: workspace (conflict) --- ⏎ 1:two ⏎ --- skipped --- ⏎ work/bin.txt: conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; sync the path to keep disk's copy and give up the workspace's, or materialize it with force to write the workspace's over disk's (5 bytes)
  • read {"path":"work/rd.txt"} (deleted in the workspace, still on disk)
    • before: work/rd.txt was deleted in the workspace; disk still has a copy. Sync this path to restore it.
    • after: The read did nothing: "work/rd.txt" was deleted in the workspace; disk still has a copy. Sync this path to restore it.
  • materialize {"path":"work/shut/sub/a.txt"} (the directory it needs can't be made)
    • before: (success) {"materialized":[],"count":0,"skipped":[{"mount":"work","reason":"shut/sub/a.txt: not written: cannot create the directory shut/sub (EACCES)"}]}
    • after: (success) {"materialized":[],"count":0,"skipped":[{"mount":"work","reason":"shut/sub/a.txt: not written: cannot create the directory shut/sub (permission denied)"}]}
  • read {"path":"lw/pf/a.txt"} (contents lost from the store; disk has a file at lw/pf)
    • before: File not found: lw/pf/a.txt. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.
    • after: The read did nothing: there's no file at "lw/pf/a.txt" any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.
  • edit_many {"edits":[{"path":"work/a.txt","oldString":"end","newString":"END"},{"path":"work/none.txt","oldString":"a","newString":"b"}]}
    • before: The edit_many call stored none of its edits: 2 problems. ⏎ ⏎ edit 1: File not found: work/a.txt ⏎ ⏎ edit 2: File not found: work/none.txt
    • after: The edit_many call stored none of its edits: 2 problems. ⏎ ⏎ edit 1: there's no file at "work/a.txt". ls or glob lists the files that exist. ⏎ ⏎ edit 2: there's no file at "work/none.txt". ls or glob lists the files that exist.
  • ls {"path":"work"} (after an append to the record failed)
    • before: Disk-agreement evidence is unavailable until the store is reopened, as at the next start: an append to its journal failed inside the store, and the evidence may lack what it recorded
    • after: The ls did nothing: the workspace can't check its files against disk until its store is reopened: an append to its record of what disk last agreed with failed inside the store, so that record may lack what the append held. Calling again won't change that; whoever runs this host can reopen it with a restart.
  • read_image {"path":"follow/out.png"}
    • before: Symlink escape detected: follow/out.png
    • after: The read_image did nothing: "follow/out.png" leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. Calling again won't change that; if a mount holds that file, name it by its path there.
  • read {"path":"follow/out.png"}
    • before: follow/out.png is not in the workspace, and disk could not be checked (a symlink that leaves the mount).
    • after: The read did nothing: "follow/out.png" isn't in the workspace, and disk couldn't be checked: a symlink that leaves the mount. Calling again won't change that; if a mount holds the file it leads to, name it by its path there.
  • read {"path":"follow/dangling.txt"} (a symlink to nothing, on a mount that follows links)
    • before: follow/dangling.txt is not in the workspace, and disk could not be checked (a symlink whose target cannot be read (ENOENT)).
    • after: The read did nothing: "follow/dangling.txt" isn't in the workspace, and disk couldn't be checked: a symlink whose target cannot be read (nothing is there). Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.
  • materialize {"path":"follow/dl.txt","force":true} (the same, forced)
    • before: (success) {"materialized":[],"count":0,"skipped":[{"mount":"follow","reason":"dl.txt: not written: something appeared at this path since it was checked"}]}
    • after: (success) {"materialized":[],"count":0,"skipped":[{"mount":"follow","reason":"dl.txt: not written: a symlink that leads nowhere is at this path, and a push makes a file only where nothing is, forced or not; whoever runs this host can remove the link or make it lead to a file, and a materialize then writes it"}]}
  • read {"path":"outer/inner/x.txt"} (a path inside a nested mount, given through the outer one)
    • before: "outer/inner/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".
    • after: The read did nothing: "outer/inner/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".
  • read {"path":"board/tools.md"}
    • before: Generated file belongs to another agent
    • after: The read did nothing: "board/tools.md" is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.
  • write {"path":"board/tools.md","content":"x"}
    • before: Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead
    • after: The write did nothing: "board/tools.md" is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.
  • read_image {"path":"gone/a.png"}
    • before: Mount unavailable: gone
    • after: The read_image did nothing: the directory of mount "gone" isn't on disk, so nothing in it can be read. Calling again won't change that; whoever runs this host can put the mount's directory back at its path.
  • read {"path":"gone/a.txt"}
    • before: gone/a.txt is not in the workspace, and disk could not be checked (the mount root is unavailable).
    • after: The read did nothing: "gone/a.txt" isn't in the workspace, and disk couldn't be checked: the mount root is unavailable. Calling again won't change that while the mount's directory is gone from its path, and checks it afresh otherwise; whoever runs this host can check the directory and its path.
  • materialize {}
    • before: Cannot materialize: [pin] current branch "earlier" has diverged from the branch last materialized to disk ("main" at seq 9) — disk may hold state the current branch never had. Pass force: true to overwrite disk from the current branch if it is canonical.
    • after: The materialize did nothing: mount "pin": current branch "earlier" has diverged from the branch last materialized to disk ("main" at seq 9) — disk may hold state the current branch never had. ls with path "pin" and recursive: true shows how each file of the current branch stands against disk; if the current branch is what disk should hold, pass force: true to overwrite disk from it.
  • write {"path":"dro/a.txt","content":"x"}
    • before: Mount "dro" is read-only
    • after: The write did nothing: mount "dro" is read-only. Calling again won't change that; whoever runs this host can make it read-write. The mounts that take writes are "dref", "dgone" and "dsmall".
  • edit {"path":"dref/bin.dat","oldString":"a","newString":"b"}
    • before: dref/bin.dat is binary, so the workspace doesn't edit it as text.
    • after: The edit did nothing: "dref/bin.dat" is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.
  • read {"path":"dref/bin.dat"}
    • before: dref/bin.dat is binary, so read doesn't show it as text.
    • after: The read did nothing: "dref/bin.dat" is binary, so read doesn't show it as text. No tool shows it as text, so calling again won't change that.
  • write {"path":"dref/sub","content":"x"}
    • before: The write did nothing to dref/sub: a directory.
    • after: The write did nothing to "dref/sub": a directory. A file can't be written over a directory; give another path.
  • edit {"path":"dref/none.txt","oldString":"a","newString":"b"}
    • before: File not found: dref/none.txt
    • after: The edit did nothing: there's no file at "dref/none.txt". ls or glob lists the files that exist.
  • edit {"path":"dsmall/a.txt","oldString":"one","newString":"three"}
    • before: dsmall/a.txt is over the size limit (4 bytes), so the workspace doesn't edit it.
    • after: The edit did nothing: "dsmall/a.txt" is 7 bytes, past the 4-byte limit this mount sets on a file, so the workspace doesn't edit it. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.
  • write {"path":"dref/sub/n.txt","content":"x"} (its directory is read-only)
    • before: The write did nothing to dref/sub/n.txt: cannot make the staged file in its directory (EACCES): a direct write needs to write the file's directory as well as the file.
    • after: The write did nothing to "dref/sub/n.txt": cannot make the staged file in its directory (permission denied): a direct write needs to write the file's directory as well as the file. Calling again won't change that; whoever runs this host can change the permissions.
  • delete {"path":"dgone/a.txt"}
    • before: The delete did nothing to dgone/a.txt: the mount root is gone (ENOENT), and calling again is refused the same way until whoever runs the host puts a directory back at the mount's path.
    • after: The delete did nothing to "dgone/a.txt": the mount root isn't on disk. Calling again won't change that; whoever runs this host can put the mount's directory back at its path.
  • write {"path":"dgone/a.txt","content":"x"}
    • before: The write did nothing to dgone/a.txt: the mount root is unavailable.
    • after: The write did nothing to "dgone/a.txt": the mount root is unavailable. Calling again won't change that; whoever runs this host can put the mount's directory back at its path.
  • read {"path":"dgone/a.txt"}
    • before: Mount unavailable: dgone
    • after: The read did nothing: the directory of mount "dgone" isn't on disk, so nothing in it can be read. Calling again won't change that; whoever runs this host can put the mount's directory back at its path.

  • Changelog fragment added: changelog.d/workspace-answers.changed.md

Lottie-2661 (Claude Opus 5.5) wrote and posted this description, from Pippa-2571's facts.

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

…en and the way on

The workspace's tools now word the answer to a call they refuse, or one
that fails, one way: what didn't happen, what the agent gave, then the way
on, or that calling again won't change it and who can.

- answers.ts holds the shared pieces. A Refusal is an error whose message
  is written for the agent; didNothing leads it with "The <tool> did
  nothing: "; onlyTheHost says calling again won't change it and who can;
  errnoWords gives an errno in plain words rather than Node's message,
  which names the host's path, and errnoWayOn what follows from it: who
  can fix it, whether a second call may get past it, or that a second call
  meets it again. errnoCode takes an error's errno from its code, or, for a store's
  I/O failure, from the OS error number Chronicle's "IO error: " message
  ends with, off Windows.
- runToolCall's catch leads a thrown Refusal with the call, answers a
  WorkspaceReadError by its code (readRefusal), and frames anything else
  with the call and its path. For a write, edit, edit_many or delete that
  failed before its change was stored (NotStored), the answer says so;
  where every change was one its files already held (AlreadyHeld), it
  says the workspace holds it either way; otherwise it says whether
  anything changed isn't known, and how to look. Where the call left the
  store awaiting its reopening, it says only a restart ends that, and so
  do the answers to a change stored before a failure that left it so. The
  evidence refusal has its own class (EvidenceUnavailable): where that
  state began during a changing call, the answer says the call stopped
  partway.
- The parse refusals (a path naming no mount, a separator right after a
  mount's name, a path that climbs out of its mount, a lone surrogate) are
  Refusals with a way on; a path naming no mount lists the mounts and
  offers the closest name. readFileFromDisk takes their code from a field,
  and throws a parse error that isn't a refusal as it is.
- readFileFromDisk's messages for what it finds on disk keep their words,
  since a host quotes them and adds its own remedy by code; the tools
  translate them. On a mount the workspace stores, readBinary keeps "File
  not found: <path>" for a file neither has, which connectome-host
  compares with readFileFromDisk's message.
- Each answer goes through those helpers: a mount's root, a directory, a
  missing file, a read-only mount, a size limit (pastSizeLimit), a
  generated file, read_image's image problems, a file disk holds that the
  workspace doesn't store, a direct mount's write refusals (with their way
  on, WriteRefused.wayOn and .errno), grep's pattern and conflict skip
  (settleConflict), a mount field naming no mount, acceptRoot with a path,
  an unknown tool, a mount root that is gone or can't be checked
  (ROOT_UNCHECKED), a read offset between a surrogate pair's halves, and
  the wake notice's conflicts line.
- Three texts that were false: a forced push onto a followed symlink that
  leads nowhere said something had appeared; a mount's root was answered
  with the store's "Invalid tree path" or a parent resolving outside the
  mount; and while the evidence awaits reopening, a lost file's answer
  offered a write and a delete that state refuses, which it now offers for
  after a restart (DiskAgreement.awaitsReopen).
- A diverged branch names ls with recursive: true as what shows how each
  file stands against disk. ls of a missing path or of a file, and
  materialize or sync of a path held nowhere, which answered empty, add a
  note saying so.
- Errnos in observe.ts's notes, direct-write.ts's refusals and the frame
  are plain words; reconcile.ts matches the notes it reads in their new
  words.

Not changed here: the reasons a push gives, the walk's "cannot list", the
reasons for a symlink among a path's directories, a direct delete's unlink
failure, edit's and edit_many's answers for a problem with an edit, and
readBinaryFromDisk's words, which its tests pin for hosts.

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

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 4 medium severity findings can be fixed after the merge.

Reviewed commit df33452 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This fits. It rewords the refusal and failure answers of the workspace module's existing tools into the "did nothing: …, then the way on" form that the module's newer answers already use.

5 reasons with sources

The new helpers sit in the module's own directory, and it comes with tests and a changelog fragment.

  • It changes how existing tools answer a refused or failed call, and adds no new feature or scope. The diff rewords answers from write, edit, edit_many, delete, read, read_image, ls, materialize and sync, plus the host-facing readFileFromDisk/readBinary/writeBinary. No new tool, dependency or service is added. (src/modules/workspace/index.ts)
  • It extends a form the module already uses, so it doesn't add a second way of doing this. On main the module already answers with 'The edit did nothing: …' (edit-text.ts:296) and 'The ${call.name} call did nothing: …' (index.ts:2200, 4211, 4272). answers.ts pulls that form into shared helpers (didNothing, onlyTheHost, errnoWayOn), and the older ad-hoc messages now go through them. (src/modules/workspace/edit-text.ts; src/modules/workspace/index.ts)
  • The new helpers live inside the workspace module, beside the files that use them. answers.ts is a new file in src/modules/workspace/ and is imported by direct-write.ts, observe.ts, effects.ts and index.ts in the same directory. It reuses the framework's existing boundedErrorMessage. (src/modules/workspace/)
  • It follows the house rules: tests for the changed behavior, a changelog fragment, and a declared break for hosts. It adds changelog.d/workspace-answers.changed.md. That fragment has a bold section for hosts that call readFileFromDisk and related functions: refusal codes stay the same, but the wording changes, so code matching on the words needs updating. The diff also adds test/workspace-answers.test.ts and updates the existing workspace tests. (CONTRIBUTING.md (What a PR should contain; Changelog))
  • A maintainer may want to note the bundled ls/materialize/sync note field, but it serves the same aim. Adding a note when ls is given a missing path or a file, or when materialize or sync is given a path held nowhere, slightly widens those tools' output. It answers the PR's own problem: these calls answered empty, the same as for an empty directory. It stays inside the same 'say what didn't happen' change. (changelog.d/workspace-answers.changed.md)

Findings

4 findings of medium or high severity, see the inline comments:

3 more findings, less severe
  • Low severity, maintainability, src/modules/workspace/index.ts:6847-6857. Same disk condition gets different ways on depending on the tool. uncheckedFile picks its advice by matching note strings, so errno-based notes such as cannot open (permission denied) fall through to the generic 'Calling again checks disk again; ... fix it on disk'. For the same file, read_image goes through readRefusal and says 'Calling again won't change that; whoever runs this host can change the permissions'. The new tests pin both forms. The same applies to a missing mount root: read of a store mount advises checking the directory, while read_image says to put it back. The errno is lost when the note is turned into a string. Carrying it would let errnoWayOn be used here too.
  • Low severity, src/modules/workspace/index.ts:1587-1592. holdsAnythingAt lists a whole subtree just to test whether it is empty. store.treeList(tree, ${relativePath}/).length > 0 loads every entry under the prefix, in up to two trees. materialize and sync call it whenever a named path wrote, deleted and skipped nothing. On a large, fully synced directory, that is a no-op call which still materializes the full listing on every call. An existence check, or a limited listing, would avoid this.
  • Low severity, test coverage, test/workspace-answers.test.ts:558-563. writeBinary's new mount-root refusal is untested. The PR adds if (relativePath === '') return namesRoot(...) to writeBinary (index.ts:2433) and lists writeBinary among the callers fixed for a mount's root. The root test covers only the write, read, read_image and delete tools. Nothing calls m.writeBinary('work', ...), so the change of writeBinary's root answer away from the store's Invalid tree path is unverified.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment on lines 3554 to 3562
} catch (err) {
// A link's refusal (parseFilePath) carries its own code.
if (err instanceof WorkspaceReadError) throw err;
const message = err instanceof Error ? err.message : String(err);
const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
: err instanceof NestedMountError ? 'other_mount'
: message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
throw new WorkspaceReadError(code, message, 'parse');
: err instanceof PathRefusal ? err.code : null;
if (code === null) throw err;
throw new WorkspaceReadError(code, (err as Error).message, 'parse');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. readFileFromDisk now rethrows non-refusal parse errors raw, which breaks its documented contract.

Before this change, any error from parseFilePath became a WorkspaceReadError (code traversal, stage parse). Now if (code === null) throw err; rethrows the error unchanged, and the new test pins a plain Error with no code. The JSDoc above still says the method throws WorkspaceReadError (lines 3536-3541). The WorkspaceReadError doc (lines 243-247) promises messages carry only the mount-prefixed path, never an absolute path. A host that does catch (e) { if (e instanceof WorkspaceReadError) ... }, or that shows e.message to an agent, now gets an untyped error whose message is uncontrolled. Either keep the wrapping, or update the docs and the companion hosts.

Comment on lines +6909 to +6915
case 'not_found':
if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
if (err.errno === undefined && (err.stage === 'lstat' || err.stage === 'fstat')) {
return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
}
if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
return unreadable(shown, err.errno);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. readRefusal decides 'pipe, socket or device' from a missing errno rather than from the message.

For code not_found, any error at stage lstat or fstat with errno === undefined is described as a pipe, a socket or a device. openContainedFile also throws Unable to read file: <path> at stage lstat (line 3382) and fstat (lines 3417-3422) with errnoOf(err), and that is undefined whenever the thrown error has no string code. Such a read failure would tell the agent the file is a FIFO or device and that only the host can replace it with a regular file. Check the Not a regular file message, or add a dedicated field, instead of using the absence of an errno.

case 'ETIMEDOUT': return 'the system call timed out';
case 'ESTALE': return 'a stale network file handle';
case 'EINVAL': return 'the system refused an argument as invalid';
case undefined: return 'an error the system gave no code for';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Errors with no errno lose their message in disk-check reasons.

Callers in observe.ts and direct-write.ts changed ${code ?? String(err)} to ${errnoWords(code)}. When there is no code, errnoWords(undefined) returns 'an error the system gave no code for'. Before, the agent got the actual error text. Nothing on these unobserved or refused paths logs the original error, so neither the agent nor the operator can see what failed. Fall back to a bounded message, or log the error.

Comment on lines +2242 to +2245
const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
if (err instanceof EvidenceUnavailable && reopened && changesFiles(call.name)) {
return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. 'Stopped partway' depends on module-wide state that a concurrent call can change.

failureAnswer treats an EvidenceUnavailable as a mid-call stop whenever agreement.awaitsReopen went from false to true between this call's start and its failure. That flag is shared across mounts and calls. If call A latches it while call B, for example a write on another mount, is under way, and B is then refused by ensureReconciled before it does anything, B's answer says it 'stopped partway' and that whether it changed anything isn't known. That is false. Track whether this call itself reached the point of change.

Pippa-2571 and others added 2 commits October 26, 2026 12:20
- A tool call that met the evidence refusal said it "stopped partway"
  whenever the store came to await its reopening after the call began. A
  call waits on its path's parse, and others, before its first evidence
  read, so another call's failed append in between made a refusal before
  any effect read as partway. The answer now asks whether this call itself
  passed an evidence read first (callGate, an AsyncLocalStorage mark that
  ensureReconciled sets); if not, the call did nothing.
- readRefusal took a missing errno at lstat or fstat for a pipe, a socket
  or a device. A read that fails there without a code said so too. The
  non-regular refusal is now its own class (NotRegularFile), which hosts
  see as the same WorkspaceReadError with the same code, stage and
  message.
- A system error with no code lost its words where main quoted it: the
  disk checks in observe.ts and direct-write.ts, a link whose check failed,
  and a direct delete's root. systemWords gives an errno in plain words,
  else the error's own message, cut at its bound.
- readFileFromDisk's doc names what it throws besides its refusals.
- The notes for a materialize or sync of a path held nowhere quoted the path
  as given; they now name it as disk spells it, as every answer but the
  include's own refusal does. A test that runs only where disk folds case
  pins sync's; materialize's takes its path from the same parse.
- A stored change whose push didn't run, where the store awaits its
  reopening, says the materialize that writes it works once the store is
  reopened. An unchecked file whose mount root a pass found unavailable, and
  a direct write's root refusal with no errno, no longer say calling again
  may work, since a gone directory stays gone; a read whose root check found
  another directory at the path still does, since a call resolves the path
  afresh. A root that can't be read gives
  the same way on in a read, a direct write and a direct delete
  (ROOT_UNREADABLE). EFBIG's fix no longer names the mount, since the
  file may be the store's.
- Tests: the interleaving, with the other call's append failing while this
  call parses its path, and nothing stored; a write and a writeBinary the
  store recorded before failing, while another call's append left the
  store awaiting its reopening; a read that fails with no code;
  systemWords and linkWords on an error with no code; writeBinary of a
  mount's root. The partway test latched inside the materialize's own
  context, which set the call's mark itself; latched outside it, during the
  blob read before the push, the materialize did nothing (disk lacks the
  file, by run) and now says so. The test now latches outside the call's
  context after the push wrote the file, and checks disk holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brought anima-research#813, anima-research#697, anima-research#366, anima-research#353 and anima-research#624 (a nested mount's directory
compared as disk compares names). Where anima-research#624 met this change:

- effects.ts and observe.ts: main's judgement of a nested mount's place
  (holder.names, judgedByInclude, an awaited nestedAtReal), with this
  change's LEAVES_MOUNT for a link that leaves the mount.
- index.ts: main's imports from observe.ts with this change's DIRECTORY,
  LEAVES_MOUNT, NOT_FOLLOWED and NOT_REGULAR. NestedMountError keeps main's
  constructor and its "may lie in" words, and stays a Refusal, so the
  tools lead it as they do every refusal.
- test/workspace-path-spelling.test.ts: anima-research#624's tests that pin a tool's
  refusal now pin it led ("The read did nothing: ...", "The write did
  nothing: ..."), a link's reason as a sentence, and a followed link out of
  its mount in readRefusal's words; main's refusedAsInner takes the place
  of two of this change's literal assertions. The tests that run only where
  disk folds case, or folds a sharp s with ss, were edited by reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/workspace-answers branch from 6ba23aa to f3ac283 Compare October 26, 2026 17:26
anima-research#624's test "an entry's spelling takes no path out of a nested mount's
directory that holds it as disk spells it" runs only where disk folds case,
and pinned the write's refusal unled. macOS CI at f3ac283 found it: the
answer is "The write did nothing: ..." like every other refusal, and the
pattern now says so. Edited by reading; macOS CI runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pippa-2571 and others added 3 commits October 26, 2026 16:56
…s it

Two reviews of the merge round found ways on for a mount root that weren't
true in every state that reaches them:

- An unchecked file's note, ROOT_UNAVAILABLE with no errno, can mean the
  mount's directory is gone or that its path came to name another
  directory during the call; the walk's note doesn't say which. Its way on
  said calling again won't change that until the directory is back, which
  is false for the second. It now says both: calling again won't change
  that while the directory is gone from its path, and checks it afresh
  otherwise.
- A direct write's root check now finds what is at the root's path when
  the check gives no errno, as a read's does (rootAbsence, moved to
  observe.ts for both): nothing or something other than a directory gives
  "put the mount's directory back at its path"; a directory there, as when
  the root the call resolved at its start came to name another one, gives
  ROOT_RECHECK. Every refusal of that check, including one whose realpath
  failed, takes one way on (rootWayOn): an errno's own fix where it has
  one, else ROOT_RECHECK.
- ROOT_RECHECK, "Calling again may work; if it fails the same way,
  whoever runs this host can check the mount's directory and its path.",
  is the way on for a root errno with no fix of its own (ESTALE,
  ETIMEDOUT, EINVAL) in a read, a direct write and a direct delete, in
  place of ROOT_UNREADABLE's permission remedy, which fitted none of them,
  and for a read's root with no errno, whose words it already was.
- A read resolved its mount's root before it read the evidence, so with no
  whole evidence (a host that kept the module after initStore's open
  threw) a root that couldn't be resolved answered with a retry or "put it
  back", though every read is refused until the store is reopened. The
  evidence is now read first, and its refusal answers.

ONCE_REOPENED moves above storedButUnfinished's doc comment, which had
come to sit on the constant, and readRoot's doc comment says the evidence
is read before the root is resolved.

Tests: the gone-root test is turned round to the unchecked file's new
words, and adds a direct mount whose directory is now a file, written and
read; a direct mount whose path is repointed during a read and during a
write answers ROOT_RECHECK, the refused write writes nothing, and calling
again then reads and writes the directory the path names; a read and a
write whose root realpath throws ESTALE, and a delete whose root stat does,
answer ROOT_RECHECK, and a write whose root realpath throws EACCES gives
the permissions fix; a
read_image, which reads disk through readRoot, with no whole evidence and
its mount directory gone answers the evidence refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brought anima-research#826, anima-research#819, anima-research#824, anima-research#821, anima-research#825 (ignore and include read
gitignore's forms, and refuse any other), anima-research#815, anima-research#820 and anima-research#818 (answers that
write say where the change stands on disk). Where they met this change, in
src/modules/workspace/index.ts:

- the imports from observe.ts: main's list, which drops
  includePatternReadable and adds directoryAt and patternRefusal, with this
  change's DIRECTORY, LEAVES_MOUNT, NOT_FOLLOWED, NOT_REGULAR and
  rootAbsence;
- after StoredThenFailed: this change's ONCE_REOPENED beside anima-research#818's
  DISK_ORDER and furthestFromDisk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ence

With no whole evidence, readRoot reads the evidence before it resolves the
mount's root (c23ed61), so readFileFromDisk's error there is the evidence
refusal at stage `root`, with what is at the root's path as its errno,
where main gave a root that doesn't resolve at stage `realpath_root`. The
no-whole-record test now asserts it: code mount_unavailable, stage root,
errno ENOENT for the removed mount directory, and the evidence refusal's
words in the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 27, 2026
Two tests pinned agent-framework's own sentence for a path no mount
holds, and one also its sentence for a path that climbs out of its
mount. agent-framework anima-research/agent-framework#822 rewords both,
and these tests failed against it while the host's behaviour held.

- "an unknown mount fails open with one warning and one alert"
  (test/instructions-module.test.ts) expected the alert to contain
  "Unknown mount". It now expects the host's "no instructions injected: "
  and, in the reason after it, the path's first segment and the mount
  that exists, which both releases name.
- "refuses a path no mount holds, and one leading out of its mount, each
  with its own reason" (test/identity-and-surfaces.test.ts) expected the
  exact sentence. It now expects the host's 'Nothing was sent to
  "orrery": ' and a reason naming "nope" and "files". Its out-of-mount
  case checked that agent-framework's message starts "Path traversal
  detected:", which #822's doesn't; it now checks that the message names
  the path, and still that the upload's refusal is that message.

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 27, 2026
@nissa-seru
nissa-seru merged commit 913afe3 into anima-research:main Oct 27, 2026
12 checks passed
tel-0s added a commit that referenced this pull request Oct 28, 2026
110 commits from main, 20 pull requests: #852 (supplied text can't read
as the framework's own), #853 (every check of whether an agent sees a row
asks its view), #827 (the heartbeat moves into the framework), #846
(exhausted turns re-armed), #830 (chat:system notices), #801 (the reply
judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855,
#857, #858, #859 and the 0.28.0 release. package.json takes main's
version and ranges (context-manager ^0.23.1, membrane ^0.8.0).

Conflicts:
- src/framework.ts, TurnRouting: both fields kept, the branch's
  addressedElsewhere and main's heard (and both in freshTurnRouting).
- src/framework.ts, createAgent: main's `sees` predicate, recorded in
  storeSees, is the branch's composition: heldFromResidents (the
  RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view
  filter reads it.
- src/framework.ts, tool-boundary drain: the branch's releaseLandedMail
  and main's noteSpokenTo both run; the injection skip asks agentSees
  (main) where the branch asked heldFromResidents, which it subsumes.
- src/framework.ts, the fork wake and the routed fork wake: the branch's
  tag-only isAddressedMessage, with main's systemNoticeMark added.
- src/framework.ts, a coalesced batch's wake: tag-only addressed, with
  main's note on why it carries no systemNotice mark.
- src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers
  each asked their own view) over the registry's sharedSlotReaders.
- src/framework.ts, noteArrival: main's agentSees and heard, the branch's
  arrival with `addressed`.
- src/framework.ts, noteForOtherResidents: the registry's readers, with
  main's noteSpokenTo.
- src/framework.ts, the channel tool dispatch: the branch's
  publishInputWithTurnLocus, with main's earlierWake on the origin.
- src/framework.ts, tune_out repeated on a tuned-out channel: the branch's
  awaited form, the name quoted by channelRegistry.quotedShown (#852).
- src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's
  noteDelivered sets the locus); main's chat:system is a marker
  (isMarkerTagged), so noteDelivered's rule covers it, and its comment
  says so.
- src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId
  form for a channel no name picks alone, a name written by quotedValue.
- src/tune-out/coordinator.ts: the branch's readOptions, all-except
  refusal, renderHeldLine/historyTool, reader-fork invocation and
  cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts
  and suppliedValue author adopted in each.
- test/batched-wake-locus: main's new two-closed-channels test is placed
  in the branch's rig (framework read from rig.framework()); the branch's
  ambient-only tests kept.
- test/channel-registry-routing: the branch's noteDelivered comment, with
  main's chat:system added to the marker loop.

Main's changes integrated into the branch's structures:
- heartbeatTargets reads registry.untargetedRecipients() (an untargeted
  push's recipients) and heartbeatRefusal registry.homeChannel, in place
  of conversationAgentHomes.
- releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm
  and #840's exhausted-turn record, and ends the run of stated waits
  (endStatedWaitRun).
- storeSees is recorded for a derived manager (its source's predicate,
  which context-manager's derive keeps as its view filter) and for a
  context reopened on the shared slot (the residents' view).
- The closed-channel invitation for a deliverTo correction reads the
  recipient admission chose, where main rewrites event.targetAgents.
- #852 on the branch's own texts: authorLine/presentAuthor write the
  author through suppliedValue; the addressed-elsewhere notice's author
  too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their
  notices use them; tune-out's branch-only notices use NOTICE_TAG and
  name channels by shown/quoted names.

Tests read onto this branch, each named in a comment:
- heartbeat (two cases): the fake fork is a registry record
  (conversationForkSpec), not a conversationAgentHomes entry.
- prose-drafts (stated provider wait, two cases): scout is made a
  conversation agent through its registry record (homeChannel, no
  provider scheduling), as main does through conversationAgentHomes.
- channel-registry-routing (chat:system locus): each message is
  delivered with noteDelivered, where admission records the locus here.
- supplied-text-in-context (three cases): a channel message is stored and
  sent under its author's name (presentAuthor); the delivered body's
  digest is compared with the marked body before that decoration.
- tune-out-reader-queued: its fake registry gains quotedShown.

Tests: built with zero errors; 29 stale compiled tests removed. The
targeted run (dendrite-*, tune-out-*, batched-wake-locus,
channel-registry-routing, compression-after-release, stored-injections and
main's added or changed framework-area tests, 63 files) gave 2507 tests,
2491 pass, 14 fail, 2 skipped before the readings above; after them, 73
files (those 63 and all-except-unit, all-except-e2e, author-presentation,
module-rows-held-from-residents, refusals-are-error-results,
state-read-and-write-errors, inbound-source, reinit-after-branch-move,
channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass,
0 fail, 2 skipped (main's runtime-conditional skips). The full suite was
not run.

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