Repository navigation
fix(workspace): answer a refused or failed call with what didn't happen and the way on - #822
Conversation
…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>
There was a problem hiding this comment.
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
notefield, but it serves the same aim. Adding anotewhen 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:
- Medium severity. readFileFromDisk now rethrows non-refusal parse errors raw, which breaks its documented contract.
- Medium severity. readRefusal decides 'pipe, socket or device' from a missing errno rather than from the message.
- Medium severity. Errors with no errno lose their message in disk-check reasons.
- Medium severity. 'Stopped partway' depends on module-wide state that a concurrent call can change.
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 ascannot 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 > 0loads 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 addsif (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 callsm.writeBinary('work', ...), so the change of writeBinary's root answer away from the store'sInvalid tree pathis unverified.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| } 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'); | ||
| } |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
| 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.`; | ||
| } |
There was a problem hiding this comment.
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.
- 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>
6ba23aa to
f3ac283
Compare
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>
…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>
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>
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>
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:ENOSPC,writeandeditansweredENOSPC: 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.writeof a mount's root ("work") answered the store's ownInvalid operation: Invalid tree path: ''.readordeleteof a mount's root answeredwork 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.Unknown mount: "Work". Available: work, docs, …(no closest name; for/work/a.txt,Unknown mount: ""),Unknown mount: Docsfor amountfield,Mount "docs" is read-only,Content exceeds max file size (16 bytes),File not found: work/nope.md,Generated file belongs to another agent, andread_image'sInvalid PNG image: …,Unsupported image format: …,Image file exceeds max size (16 bytes): …,Unable to read image file: …(the errno dropped).cannot open (EACCES),cannot make the staged file in its directory (EACCES).materializeonto a symlink that leads nowhere, on a mount that follows links, saidnot written: something appeared at this path since it was checked. Nothing had appeared.read_imageof 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.materializeheld back by a diverged branch said to passforce: true"if it is canonical", with nothing to judge that by.lsof a missing directory, or of a file, andmaterializeorsyncof a path the workspace never held, answered empty, as an empty directory does.Changes
answers.ts). ARefusalis an error whose message is written for the agent: why the call did nothing, then the way on.didNothingleads it with what didn't happen,The <tool> did nothing:, the formmain's newer answers already use.onlyTheHostsays that calling again won't change it and who can:Calling again won't change that; whoever runs this host can ….errnoWordsgives an errno in plain words, anderrnoWayOnwhat 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.errnoCodereads an error's errno from itscode, or, for a store's I/O failure, from the OS error number its message ends with.unexpectedAnswerframes an error no handler threw on purpose.runToolCallleads a thrownRefusalwith the call's name, answers aWorkspaceReadErrorby its code (readRefusal), and frames anything else with the call and its path. For awrite,edit,edit_manyordeletethat 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 (readthe file, orlsof the mount withrecursive: trueformaterializeandsync). 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 awrite,edit,edit_many,delete,materializeorsyncmet 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.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.readFileFromDisktakes 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'sWorkspaceReadErrormessages 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, aWorkspaceReadError, carries a root refusal's reason as a field, which the translation anddeleteDirectread instead of the message. On a mount the workspace stores, for a file neither the workspace nor disk has,readBinaryusesreadFileFromDisk's words,File not found: <path>.read_image: an image's own problems (empty,too_large,truncated,invalid,unsupported) areRefusals with a reason and a way on. A disk failure stays aWorkspaceReadErrorforreadRefusal, so the mapping that matched on message text goes.write,read,read_image,deleteorwriteBinary, is answered as not a file, ineditablePath's words; a path naming a directory is answered as a directory, withlsto list it;edit's words for the same limit (pastSizeLimit);set_tool_visibilityandset_tool_descriptionchange it;read_imageonly for an image within the mount's size limit;WriteRefused.wayOn), a directory the write can't make, a file where a directory should be and a landing outside the mount included, and anedit_manythat 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, inread's words for a conflict (settleConflict), so a read-only mount isn't offered a materialize;mountfield naming no mount says what leaving it out covers for that tool;syncwith bothacceptRootandpathnames the fieldacceptRoottakes, and the mount where the path names one;unknownToolMessage), whatever path it names;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'sforcedWaymatches the new note.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, fromDiskAgreement.awaitsReopen). A diverged branch nameslsof the mount withrecursive: trueas what shows how each file stands against disk.lsof a missing path or of a file, andmaterializeorsyncof a path held nowhere, add anotesaying 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:
materialize's andsync'sskipped, the push reason in awriteordeletethat was stored but whose push didn't complete) and the walk'scannot listkeep their words, errno codes included, such ascannot open the file (ELOOP), apart from where one quotes a note ofobserve.ts's, whose errnos are now plain words, such ascannot verify disk copy (cannot open (permission denied)).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 andedit_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'sreadBinary's words for a file over the size limit or missing,File too large: <size> > <limit> bytesandFile not found on disk: <path>, which their tests pin as their words for hosts.What each part is for
Refusalmarks 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,EvidenceUnavailableandWorkspaceImageReadErrorareRefusals for that reason.EvidenceUnavailableis the evidence refusal's own class, so the catch can tell it from a refusal thrown before the call did anything.callGatemarks, per call, whether one of the call's own evidence reads passedensureReconciled. 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.reopenAtStartsays whether the store already awaited reopening as the call began, for the unexpected-error frame's reopen sentence.systemWordsgives an errno in plain words and an error with no code in its own words, cut at its bound, at every disk check that quotedcode ?? String(err)but the walk'scannot list, which keeps its words.NotRegularFilemarks the refusal of a pipe, a socket or a device, soreadRefusaldoesn't call a refusal with no errno a pipe, a socket or a device. Hosts see the sameWorkspaceReadError, with the same code, stage and message.PathRefusal'scodegivesreadFileFromDiskthe code it took frommessage.startsWith('Unknown mount').didNothing'stokeeps the direct mounts' form,The write did nothing to "<path>": <reason>.onlyTheHost,errnoWords,hostFixanderrnoWayOngive the words every refusal and failure share for who can fix it and what an errno means.PASSINGholds the faults a second call may get past (EIO,EMFILE,ENFILE,ENOMEM), andAS_DISK_ISthose that follow from what disk holds, so a second call meets them again.errnoWayOn'shostsNamesays 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).hostFixgivesEFBIGits own fix, a file-size limit or another filesystem, anderrnoWordsnamesEWOULDBLOCKwithEAGAIN.errnoCodetells a Node system error, whose message can name the path the call was given, from an error whose words can be quoted.OS_ERRNOmaps 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 codeGenericFailureand a message such asIO error: Permission denied (os error 13)(a run on Linux). Only anIO 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.changesFilesnames the calls whose failure can leave a change behind, for which the frame says whether anything changed.unexpectedAnswerand itsUnexpectedoptions 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, whichstoredButUnfinished's andeditsStoredButUnfinished'sreopen, andwriteBinary's stored failure, add too).failureAnswerchooses the answer for whatever a handler threw.NotStoredcarries an error that came before awrite,edit,edit_manyordeletestored its change, path parsing included, so the frame can say nothing was stored.AlreadyHeldcarries a store's failure in a change whose every path already held what it gave.CommitUnknownErrorkeeps its two causes, framed as unknown instoreWords: an errno in plain words, else the store's message cut as a first line is.RootUnavailable'sreasonandrefusalcarry a root refusal's reason and the evidence refusal throughreadRoot, so a tool answers in their words.readRefusaltranslatesreadFileFromDisk's refusals for the tools by code and stage;unreadablewords its disk failures.WriteRefused.wayOncarries each direct refusal's way on from where it's known, andWriteRefused.errnoits errno, so a partwayedit_manycan say who fixes it without inviting a second call;refusedWayOngives the way on for a directory, root or landing reason, from theerrnoDirCheckandcheckRootnow carry where a system call failed.NOT_A_DIRECTORYnames 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_THEREandNOT_REGULAR_THEREare ways on two or more refusals give.directWayOnanddirectDidNothingput a direct refusal's reason and way on after the lead.DIRECTORY,NOT_REGULARandLEAVES_MOUNTinobserve.tsname notes thatuncheckedFileandrefusedWayOnchoose a way on by, asNOT_FOLLOWEDand the root notes already were.noSuchMount,noSuchMountFieldandmountListword the path's and themountfield's refusals.writableNameslists the mounts that take writes, forwriteRefusal.namesRoot,tooLargeToWrite,pastSizeLimit,deletedOnDisk,uncheckedFile,sentence,settleConflict,generatedFileRefusal,lostTextandremedyOfLosteach word one answer that two or more sites give.truncatedImage,emptyImage,imageTooLargeandIMAGE_STAYSwordread_image's refusals of a file's bytes.notInWorkspace'smissinggivesreadBinaryreadFileFromDisk's words for a file neither has, which connectome-host compares withreadFileFromDisk's to give a reason both share once.emptyScopeNote,holdsAnythingAtandabsentOnDiskdecide 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_UNCHECKEDis 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_RECHECKsays 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 asESTALE,ETIMEDOUTandEINVAL. 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.ENOENTandENOTDIRsay to put the directory back. Any other errno gets its own way on where it has one, such as changing the permissions forEACCES, or, forEISDIR, that calling again won't change it while disk holds what it does there. An errno with no way on of its own getsROOT_RECHECK.rootAbsencemoves fromindex.tstoobserve.ts, so a direct write's root check uses it asreadRootdoes. Where the check gave no errno, what is at the root's path decides: nothing givesENOENT, something other than a directory givesENOTDIR, 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.readRootreads the evidence before it resolves the root. With no whole evidence, as when a host kept the module afterinitStore's open threw, every read is refused until the store is reopened, so a read answers with that refusal, not a root fault.ROOT_REPLACED's words, which say to reconnect it, or to sync the mount withacceptRootif it was replaced on purpose (observe.ts:156–158).ONCE_REOPENEDtells 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.schema !== undefined), so an unknown tool naming a generated file gets the module's tool list.awaitsReopentells 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.test/workspace-path-spelling.test.tsleads 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.tspins the answers this change adds, each with the state that reaches it; itsstoreErrorthrows a store error as Chronicle 0.4.0 does, andMAY_WORKandREOPENare frames several tests assert.workspace-file-tools.test.ts'sunstored,MAY_WORKandFULL_DISKword 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); forreadBinary's answers, but for a file neither the workspace nor disk has; for an errnoreadFileFromDiskgives from a link check, now in plain words; and for the reason afterMount unavailable: <mount>:while the store awaits reopening.readBinaryFromDisktakes the new parse refusals' words. Where the store holds no whole evidence, as when a host kept the module afterinitStore's open threw, and the mount's directory is gone,readFileFromDisk's error has the stageroot, wheremaingaverealpath_root; its code,mount_unavailable, and its errno,ENOENT, are as before, and its message,Mount unavailable: <mount>onmain, 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,readFileFromDiskthrows it as it is rather than as atraversalrefusal.In connectome-host at
3e4cf9b3, the identity module picks a refused upload's remedy from aWorkspaceReadError'scodeanderrno(diskHasNoFileanddiskRemedy,src/modules/identity-module.ts:66,:76). WherereadFileFromDisk's message equalsreadBinary's error, the module gives that reason once (:1082–1084). Some of its tests assert words this change alters, such asUnknown 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.tsunless named:write,editordeletereaches its change says nothing was stored, and nothing is.readFileFromDiskthrows such an error as it is.write,edit,edit_many,deleteorwriteBinarystored before its push's append fails.materializesays the call stopped partway. The store coming to await it through another call's failed append, while awriteparses its path, says thewritedid nothing ("…before the call reaches its change, says the call did nothing").workspace-edits.test.tspinedit_many's store failures, and one inworkspace-file-tools.test.tspins a write of bytes the file already held.writeBinary's too), an unknown tool (one naming a generated file too), an unknown mount (closest name, leading/, a bare name), an unknownmountfield (per tool), a path that climbs out, a directory, a missing file, a read-only mount (andwriteBinary's), content over the size limit (andwriteBinary'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, whichread_imagethen 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 andacceptRootwith 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.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 thels,materializeandsyncnotes.lsto show what it names.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.workspace-answers.test.tspin 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, andESTALEfrom a read's and a direct write's rootrealpathand from a direct delete's rootstat. 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 afterinitStore's open threw, andreadFileFromDisk's error then, with the mount's directory gone: codemount_unavailable, stageroot, errnoENOENT.repointedDuringrepoints the path at therealpathreadRootordiskViewmakes.workspace-path-spelling.test.ts), which this change's runs, on Linux, skip.Runs, all on Linux:
npm testat 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.main's code at3520ad49with this head'stest/workspace-answers.test.ts, 44 of its 45 tests fail, each atmain's answer, with stand-ins for what the file imports andmainlacks: asystemWordsthat givesmain'scode ?? String(err), and acallGatethat nothing reads. The one that passes,readBinary's missing file, pins wordsmainalready gives, which this change keeps.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 inworkspace-answers,workspace-file-tools,workspace-editsorworkspace-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, themountfield's clause,acceptRoot's mount, a long name's way on, the reopen sentence after a stored change, the generated-file check's tool, theIO error:form, a file where a directory should be, the partwayedit_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 gettingROOT_RECHECK.Other open pull requests
We merged
2204e73a, which this head differs from only in one test, withmainat98340a21(Oct 26), then merged each of the 42 open pull requests into that merge and intomain. None conflicts with the merge while merging cleanly withmain. #271 already conflicts withmain, and this head changes the hunks of its conflict inindex.ts. This head merges cleanly withmainat98340a21, and carriesmainat3520ad49.Not verified
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'sio::Error, whose number there is a Win32 code).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'sstd::io::Errorformat; no run made one.statwith an errno other thanENOENTorENOTDIRonce the observation found nothing there; and a store mount whose root changes within a pass, the second stateROOT_UNCHECKED's words cover. No test makes these states.What this leaves
mountand act on more than one mount #832:materializeandsyncaccept an emptymountand act on more than one mount.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' andedit'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 ofmainincluded.Each answer before and after, by run
Each call ran against a real workspace on
mainat3520ad49and on2204e73a, which this head differs from only in one test, on Node 24.22.1 on Linux 6.12; the after texts are2204e73a'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"}Unknown mount: "notes". Available: work, docs, auto, small, inc, watchedThe 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"}Unknown mount: "Work". Available: work, docs, auto, small, inc, watchedThe 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"}Unknown mount: "". Available: work, docs, auto, small, inc, watchedThe 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"}Path traversal detected: "work/../secrets.txt" climbs above the root of mount "work".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"}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.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"}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.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"}Unknown tool: reed, so nothing was doneThere'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"}"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.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"}File not found: work/nope.mdThe 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}Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.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}offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.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"}work is not in the workspace, and disk could not be checked (a parent directory resolves outside the mount).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"}work/pics is not in the workspace, and disk could not be checked (a directory).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"}Mount "docs" is read-onlyThe 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"}Content exceeds max file size (16 bytes)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"}The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can't store.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"}Invalid operation: Invalid tree path: ''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"}work/c.txt was deleted in the workspace; disk still has a copy. Sync this path to restore it.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"}Invalid PNG image: work/sig.pngThe 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"}Truncated image signature: work/short.pngThe 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"}Unsupported image format: work/notes.bmpThe 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"}Image file is empty: work/empty.pngThe 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"}Image file exceeds max size (16 bytes): small/big.pngThe 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"}Path is a directory: work/picsThe 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"}Symlinks are not allowed: work/link.pngThe 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"}Unable to read image file: work/locked.pngThe 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"}work/locked.png is not in the workspace, and disk could not be checked (cannot open (EACCES)).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"}work/shot.png is on disk but not stored in the workspace (binary or over the size limit); read it with read_image.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"}work/data.bin is on disk but not stored in the workspace (binary or over the size limit).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("}The grep did nothing: Invalid regular expression: /foo(/: Unterminated group.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"}glob pattern "work/{a,b" has a "{" with no closing "}". To match a "{" in a name, put "?" in its place.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"}Unknown mount: "nowhere". Available: work, docs, auto, small, inc, watchedThe ls did nothing: "nowhere" is no mount's name; the mounts are "work", "docs", "auto", "small", "inc" and "watched".ls {"path":"work/nowhere"}(success) {"path":"work/nowhere","entries":[],"count":0}(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"}Unknown mount: DocsThe 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"}(success) {"materialized":[],"count":0}(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}acceptRoot takes a whole mount's root: give mount, or nothing, not pathThe 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"}(success) {"results":[],"totalSynced":0,"totalConflicts":0}(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[workspace event · modified · auto/a.txt] ⏎ [conflicts: auto/a.txt][workspace event · modified · auto/a.txt] ⏎ [conflicts with disk: auto/a.txt; reading each shows how to settle it]writeBinary("docs/x.png", …)Mount "docs" is read-onlymount "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)Content exceeds max file size (16 bytes)"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"}(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)(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)work/rd.txt was deleted in the workspace; disk still has a copy. Sync this path to restore it.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)(success) {"materialized":[],"count":0,"skipped":[{"mount":"work","reason":"shut/sub/a.txt: not written: cannot create the directory shut/sub (EACCES)"}]}(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)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.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"}]}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.txtThe 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)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 recordedThe 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"}Symlink escape detected: follow/out.pngThe 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"}follow/out.png is not in the workspace, and disk could not be checked (a symlink that leaves the mount).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)follow/dangling.txt is not in the workspace, and disk could not be checked (a symlink whose target cannot be read (ENOENT)).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)(success) {"materialized":[],"count":0,"skipped":[{"mount":"follow","reason":"dl.txt: not written: something appeared at this path since it was checked"}]}(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)"outer/inner/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".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"}Generated file belongs to another agentThe 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"}Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description insteadThe 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"}Mount unavailable: goneThe 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"}gone/a.txt is not in the workspace, and disk could not be checked (the mount root is unavailable).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 {}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.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"}Mount "dro" is read-onlyThe 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"}dref/bin.dat is binary, so the workspace doesn't edit it as text.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"}dref/bin.dat is binary, so read doesn't show it as text.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"}The write did nothing to dref/sub: a directory.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"}File not found: dref/none.txtThe 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"}dsmall/a.txt is over the size limit (4 bytes), so the workspace doesn't edit it.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)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.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"}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.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"}The write did nothing to dgone/a.txt: the mount root is unavailable.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"}Mount unavailable: dgoneThe 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.d/workspace-answers.changed.mdLottie-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-seruaccount.