Skip to content

feat(workspace): a direct mount can be written, and a head move away from its writes is told once - #719

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:feat/writable-direct-mounts
Oct 23, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:feat/writable-direct-mounts

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This PR makes a direct workspace mount (backend: 'direct', from #564) writable. Its write, edit, edit_many and delete land on disk when they answer, or don't happen, and none of its files go into the store. After an undo or another move of the store's head away from an agent's direct writes, the files stay as they are, and the agent is told once, at its next start, that disk still holds them.

Problem

On a store-backed mount, the store holds the agent's copy and disk is where it's pushed. A checkout an agent codes in pays for that: every change waits on a pass or a push, and an undo splits the agent's view from disk. #564 gave a checkout a direct mount, read straight from disk, but only read-only, so an agent with such a mount couldn't write code through the workspace's tools. On a direct mount, disk is the only copy, so a write has to land whole or not at all, and an undo can't take a write back.

Changes

  • A direct mount may be writable. directRefusals no longer refuses one whose mode isn't 'read-only', and one that names no mode is writable, as any mount with none is. No configuration that works today changes: such a mount was refused before.
  • One path for every writing tool. write, edit, edit_many, delete and writeBinary keep one path for their refusals, their planning and their answers, and ask the mount where the bytes go: the store-backed kind's commit and push, or the direct kind's disk. planTargetEdits is the planning both kinds share, moved out of applyEdits unchanged, so a direct edit is planned by the same planner against disk's text as it is now, and its answer is a store-backed edit's, changed region included.
  • The direct write (direct-write.ts, a staged write of ours, behind the mount's boundary as effects.ts holds a push's):
    • The file's directory is located inside the mount (checkDir), and where it lands is judged as a push's write judges it (refusedLanding, now exported): inside the mount, in no nested mount's directory, and covered by the include. As a direct mount's reads do (feat(workspace): a read-only direct mount, reading disk as it is #564), its writes go beneath whatever directory is at its root when the call starts, with no recorded root to check against, so a root configured through a link that's repointed is written where it leads now.
    • The new bytes are staged beside the file in .workspace-staged-<id>, written and moded through the staged file's own handle, and renamed over it. A replaced file's staged copy is the owner's alone (0600) until it takes the file's mode, special bits included as far as the kernel lets the process set them, after its bytes are written, since a write clears a set-user or set-group bit. Just before the rename, the entry at the staged name is checked to be that file by its whole identity, since a file made where one was just unlinked can take its inode number; a refusal removes the staged file only on the same check. A new file takes 0666 less the umask at the moment it's made, as a shell's would. Owner, group, ACLs and extended attributes are the new file's, as with any write that renames over a file. A file the process may not write is refused, checked just before the rename, as a push's write, which opens the file for writing, is refused. A writable file in a directory the process may not write is refused too, where a push's write in place would land: the staged file is made in that directory, and the refusal says so.
    • The rename gives a hard-linked path a file of its own, and the answer's note says the other links keep the old text.
    • Just before the rename, an edit's file is checked unchanged since its read: by its bytes, read again through an open that follows no link (where the platform has the flag) and waits on no pipe, and by its identity (device, inode, size, mode, owner, group, and modification and change times) once they're read. Anything there that isn't a regular file is refused. With no turn of the event loop between that check and the renames, only a change landing in that instant isn't seen, which for edit_many lasts from the check through its last rename.
    • A file another writer changed meanwhile is read and planned again, three attempts in all, so the edit lands on top of that change. It's refused where its match is gone or occurs a different number of times, where a file changed at every attempt, or where the next read refuses it as any read would (a file now past the size limit, say), with nothing written, and the answer says when a file was read again.
    • edit_many plans, stages and checks every file before it renames any. If a rename then fails partway, the answer names each file's outcome and the hard links a written one split from, within the call's answer budget, and nothing puts the written ones back. Two paths that reach one file, through a link or as two hard links, are refused: their renames would overwrite each other. Two spellings of one file on a disk that folds case are one path already by the time the edits are planned (by run, on a drvfs disk), short of a directory link on the way; past one, they're refused as one file named twice.
    • A write replaces the file as its staging found it, mode included: a mode another writer sets during the call is replaced along with the bytes. An edit counts such a change as a change, and reads the file again.
    • On a mount that doesn't follow links, a link at the file is refused. On one that follows them, the write goes to the file the link leads to inside the mount, and the link stays.
    • delete goes through unlinkContained, the store-backed push's own unlink, with the root and its identity taken before its checks, so a root replaced during the call is refused rather than taken. One that fails after reaching the file says the file may be gone. A root replaced under a write or a deletion is refused in a direct mount's words, since the store-backed remedy, a sync with acceptRoot, has no use there.
    • Nothing is synced.
  • The head-move notice. The workspace keeps, for each agent and each mount it writes direct, the agent's latest write there, P (the store's branch and its head when the write landed), and the id of the notice of its departure from P, N.
    • At every start of a turn, opening, continuing or resuming, the framework asks the workspace (headMoveNotice). Where the history holds neither P nor N, it stores one notice in the agent's history before the start's compile, as the drafts notice is stored, and hands its id back (headMoveNoticeStored).
    • The history holds P when the current branch is P's or continues it with every fork point after P's head. A fork at exactly that head doesn't hold it, since undoLastTurn forks at its turn's sequenceBefore, which can be that head.
    • The history holds N when the agent's context holds that message. The framework hands the workspace one lookup for this when the module is added (useHistory), so a start and a write judge N alike.
    • A write moves P only where the history holds P or N, and clears N. A write made after the head moved under its turn leaves P where it was, so the next start's notice covers the writes the history lost.
    • P and N are saved at stop() as a global record (workspace/direct-heads), as the disk-agreement evidence is, not in the module's branch state, and loaded when the store is handed over, before any call can write. They describe disk, which doesn't rewind with a branch, so a restart on another branch, such as one an offline rollback makes, still finds them and gives the notice. Each mount's positions are saved as an entry with its configured root: a mount configured direct gets the entry at its own root, and every other entry, for another root, for a mount configured store-backed, or for one not configured, is kept for the next save. A session whose read of the record fails starts with none and saves none; one whose record reads but isn't of the shape saved starts with none and its stop replaces the record; and a stop that would save what the start read writes nothing. A mount configured direct that runs as 'chronicle' at a later start still gives the notice, since the writes are on disk either way. A history lookup that throws decides nothing at a start, which gives no notice and leaves the next start to decide, and counts as not held at a write, whose position stays and which still answers that it landed.
  • The framework's own files pass over a mount configured direct. firstWritableMountName skips one, so tool-result spills, saved MCP payloads and fetched references stay out of a checkout. getMounts() gives backend: 'direct' for a mount configured direct, whether it runs direct this session or, while the store holds work there that disk lacks, as 'chronicle'.
  • materialize passes over a writable direct mount, naming it under skipped where the call names it, and materializeMount writes nothing to one.

What callers will notice

  • A host with no writable direct mount running direct gets the same tool descriptions, answers and behaviour as on main, so its residents' prompt caches don't change.
  • A host whose config has a writable direct mount gets it, where main refused that config. While such a mount runs direct, its write tools' descriptions gain a sentence each saying what a write is there, since a caller needs to know before writing that it lands on disk at once and that an undo leaves it. materialize's description says it passes over such a mount. ls without a path, and status, give its mode as 'read-write' where its config names none.
  • A host that relied on agent-framework refusing mode: 'read-write' on a direct mount, such as a check of its own config, now has that mode accepted. What a direct mount still refuses (watch other than 'never', watchDebounceMs, watchRootPollMs, a wakeOnChange that would wake, and autoMaterialize: true) is refused in the same words as before.
  • A writable mount configured direct that runs as 'chronicle', because its store holds changes disk lacks, takes no writes, edits or deletions until they're settled. Each is refused with the reason and the way out, a sync of a held path or a materialize naming the mount, and the start alert says the same. In 0.24 that state took no writes either, since a direct mount was read-only.
  • The notice, in that agent's history, after a move away from its writes: "Writes to <mount> from turns the history no longer holds are still on disk. If it's a git checkout, git status and git diff show them; a git checkout of a file drops every unstaged change in it, including the ones the history still holds."
  • getMounts() has an optional backend field, an addition a caller that ignores it doesn't meet, and gives mode: 'read-only' for a mount configured direct while it runs as 'chronicle', since it takes no writes then.
  • A peer module's writeBinary to a direct mount lands on disk as write does, and counts as an agent's write for the notice only where it names the agent (options.agentName), as save_recent_image now does. connectome-host's identity module offers a writable direct mount as a place to save a response the agent asks for, as it offers any writable mount, and saves it this way without naming the agent, so such a save tells no agent.
  • A host whose only writable mount without an include is direct gets no tool-result spill file and no stored reference. Its truncation note says "no writable workspace mount without an include that keeps its files in the store", and an MCP payload it can't show says "no workspace mount that keeps its files in the store can save it", where a host with no such mount at all keeps main's words for both. A host that wants them adds a store-backed writable mount.
  • A store whose agents wrote to a direct mount gains one small global record (workspace/direct-heads/checkpoint) at each clean stop that changed what it holds: each agent's position per mount, with the mount's configured root.
  • connectome-host (since connectome-host#421) requires a recipe's direct mount to name its mode.

What each part is for

  • The shared path for refusals, planning and answers keeps one rule for each, where a second branch in every writing tool would drift, and is what gives a direct edit the store-backed answer an agent checks its edit by.
  • Locating the directory and judging the landing keep a write from leaving the mount through a link, or landing in a nested mount's directory or outside the include.
  • The staged rename keeps a build or test watcher beside the agent from reading half a file, and a killed process from leaving one truncated. The kept mode keeps an edited script runnable. Writing through the staged file's handle, and checking its entry by its whole identity before the rename, keep a name swapped for a link or another file from redirecting the mode change or the rename. The owner-only staged copy keeps a private file's new text from being readable by other users while it's staged. Removing a staged file only when it's the one the call made (by its device and inode while its handle is open, by its whole identity after) keeps a refusal from deleting a file another writer put at its name, unless a stat can't tell that file from the staged one: on the freed inode number, with the same size, mode and owner, in the same tick of a coarse clock. Checking write permission just before the rename catches a file made read-only during the call; a file gone by then is a change for an edit and nothing for a write, and only a permission error is put in permission words. A staged file whose handle fails to close is removed by its whole identity, as a refusal's is. writeBinary's branch check before the write as well as at the rename keeps a request already for a left branch from making directories or staging. A deletion's root, taken by its identity as the call starts, keeps a root replaced during the call from having its file deleted, but in the instant before the unlink; one that can't be read then is refused with its code. The fixed-length staged name keeps a file whose own name is near the 255-byte limit replaceable. Refusing a file the process may not write keeps a rename, which needs only the directory's permission, from replacing a file its owner made read-only, as a push's write never does.
  • Naming a hard-link split tells the agent that the other links, such as a package store's copy, kept the old text. A write in place would change that copy under every name it has.
  • The check before the rename, and its three attempts, keep another writer's change made during the call from being written over. The bytes are compared as well as the identity because a kernel that stamps files from a coarse clock, as, by our reading, Linux before 6.13 does, can leave a same-size change within one tick unstamped. On Debian's Linux 6.12.113 with ext4, 1,632, 1,752 and 1,739 of 2,000 such rewrites made straight after a stat kept every stamp, in three runs; on Linux 6.18, none of 2,000 did. The mode, owner and group are part of the identity for the same reason: a chmod or chown in the same tick keeps the change time. The identity, taken after the bytes, also catches a file replaced while they were read. Reading at most one byte past the file the edit read, through an open that follows no link and doesn't wait on a pipe, keeps a file another writer enlarged, or a pipe swapped in, from costing more than that; an empty pipe's answer at the read is another writer's change, read and planned again, as any other is.
  • edit_many's check of every file before any rename means a full disk while staging, or another writer's change, is met before any file is replaced. Putting written files back after a failed rename would be a second write path with failures of its own, so the answer names each file's outcome instead, fitted to the call's answer budget as every edit answer is. Refusing one file named by two paths keeps two renames from overwriting each other's edits while both report success.
  • The notice is what tells an agent whose history lost turns that disk kept their writes. Without it, its next write from memory replaces them, and its edits meet mismatches it can't explain.
    • Checking at every start is there because a head can move under a running turn without ending it: undoLastTurn checks only the agent it's given, and a context manager's branch switch works under a running turn.
    • Storing it at the start puts it in that start's compile, where a module's own message route reaches only a later one.
    • Judging "once" by the notice's presence lets the history decide: an undo that removes the notice gives it again, and a move that keeps it doesn't.
    • Clearing N when P moves keeps a later departure from hiding behind an earlier notice.
    • The stop-time save, in a global record, keeps both across a clean restart, including onto a branch forked before the writes, as an offline rollback makes; in the module's branch state that rollback would bring back older positions or none. Saving each mount's root with them keeps a mount whose path changed from being told of writes in another directory, and carrying every entry a session doesn't take up keeps one left out of a session, or moved to another path for a while, from losing them for good; a mount removed for good, and each path a mount has moved from, leave a small entry behind. Saving nothing after a failed read, or when nothing changed, keeps a session that couldn't read the record from replacing it, and idle restarts from adding records; replacing a record of another shape keeps one damaged record from keeping every later session from saving.
    • A history lookup that throws deciding nothing at a start, and counting as not held at a write, keeps a notice from being stored about a history that can't be read, and leaves it owed.
    • A deletion's position taken just before its unlink keeps a branch switch during the unlink from being taken for the branch the deletion's turn was on: where the branch changed, the position moves to where the unlink began, unless an older position's notice is owed already.
    • The framework's check that the workspace can record the notice as given (headMoveNoticeStored) keeps a module taken for the workspace that can't from being given a notice it would give again at every start.
    • Recording the notice as given before tracing it, with the trace's own failure caught, keeps a failing trace from leaving a stored notice unrecorded, to be given again.
    • Catching the store's reads after a landed write keeps a store that can't say where its head is from turning a write that landed into an error answer; the position stays, as for a history that can't be read.
    • Checking the whole saved record at load, carried mounts included, keeps a record of another shape from being carried into the next save.
    • Comparing entries by their code points when telling whether a stop would save what the start read keeps an unchanged session from adding a record where two roots are spellings a collator takes for one, which localeCompare would leave in whatever order they came.
    • Restoring positions only to a mount configured direct, and carrying a store-backed one's, keeps a mount reconfigured store-backed, whose store takes the direct writes in as disk's own changes as its listings, searches and syncs reach them, from being told they're on disk outside its history, and gives the positions back if it's configured direct again.
  • Refusing writes on a mount configured direct while it runs as 'chronicle' keeps an agent told the mount is its checkout from writing where disk never changes, and keeps what's held from growing, so a start can run it direct once it's settled; storing them would keep it 'chronicle' at every later start.
  • Passing the framework's files over a direct mount keeps spill and reference files out of a checkout, where git status would show them and an undo would leave them, including while the mount runs as 'chronicle', whose pushes would write them there.
  • The words for a host whose only writable mount is direct, in the truncation note and an MCP payload's stub (through normalizeStandardToolResult's noSave), tell its agent why nothing was kept, where main's would say there's no such mount or no workspace at all.
  • The deletion's words for a root it can't look at say what the agent can expect of calling again: a root that's gone, or one this process may not read, refuses the same way until whoever runs the host changes it, and any other failure is named by its code.
  • status and ls giving 'read-write' for a direct mount that names no mode, while it runs as 'chronicle' as while it runs direct, keep one mount from showing two modes for one config.
  • materialize passing over a writable direct mount keeps a named one from going on to a push with no tree for it: its writes are on disk already.
  • writeBinary's agentName lets a peer module's write on an agent's behalf, such as save_recent_image's, count as that agent's for the notice; a write naming no agent tells no one, since no history holds it.
  • The test seams (directWriteTestSeam's afterStage and beforeRename) let the tests put another writer's change, a failed rename and a killed process at exact points of a write; production sets neither, and each is one optional call.
  • The tests' own harnesses (a temporary mount and store per test, a real AgentFramework with a scripted membrane for the notice, and seams that wrap fs calls) pay for what each test needs to reach: a write's exact instants, and a start whose history the test chose. Each lives in its test file, and none is shared with other tests.
  • Left out:
    • putting files back on an undo, since git already versions a checkout, and a put-back would write a shared checkout at moves nobody chose for writing;
    • naming each file a head move left changed, which git status and git diff already show;
    • a sweep of staged leftovers at start, for the rarest cases: a crash between staging and renaming, or a directory moved under the call after staging, whose staged file a refusal then can't reach by its path;
    • a sync after each write, since the store-backed kind's sync serves its agreement record, which a direct mount doesn't keep;
    • a put-back for edit_many's partial write;
    • watching a direct mount.

Tests

On Linux 6.12 with Node 24.22.0 and Bun 1.4.4. This pull request's head is 86e4cfd084: the change, 781e7eccfe, on main 8e0bb04ea1, and two fix commits answering review. The full suite ran at 3a9a76976c, the change before two rounds of fixes from our internal review; at 781e7eccfe, the test files of the workspace, the tool-result paths and the MCP payloads ran, and since then the eight listed below.

  • The full suite at 3a9a76976c (npm test, after npm install, npm run typecheck and npm run build, each clean): 6,315 tests, 6,270 pass, 44 skipped and 1 failure, in 218 s wall and 369 s user CPU, on 12 CPUs at a load of 26 to 39. main's CI at 8e0bb04ea1 (ubuntu, Node 24) ran 6,230, 45 of them skipped. The failure, "under Bun, a failed dial's error leaves no credential in its message, cause or stack", fails the same way on main at 8e0bb04ea1 under Bun 1.4.4 (run on its own there: 33 of 34 in mcpl-ws-transport), and test: a refused dial under Bun may be worded as Bun 1.4.4 words it #704, merged since, updates that test for Bun 1.4.4's wording.
  • At 781e7eccfe, after a clean typecheck and build, the 19 test files of the workspace, the tool-result paths and the MCP payloads all pass: among them workspace-direct-write 60 of 60, workspace-direct-notice 26 of 26, workspace-direct 85 with 2 skipped, mcp-modern-framework 19 and mcp-tool-result-normalize 21.
  • At the head, after a clean typecheck and build, workspace-direct passes 86 with 2 skipped. On an earlier build that differs only in the refusal's words and the test that pins them, after a clean typecheck and build, seven more pass: workspace-direct-write 60, workspace-direct-notice 26, save-recent-image 54, tool-result-spill 23, tool-result-guard 150, mcp-modern-framework 19 and workspace-include 58. The change adds 89 tests: 60, 26, two in workspace-direct and one in mcp-modern-framework.
  • An earlier version of the first fix commit's test against 781e7eccfe's source: the new test fails, a write to a mount configured direct that runs as 'chronicle' succeeding into the store, and so do the three tests of the start alert's words.
  • Against main's src/framework.ts, src/modules/workspace/index.ts, edit-text.ts and types.ts, with 781e7eccfe's tests: 56 of the 60 tests in workspace-direct-write fail, and all 26 in workspace-direct-notice, each with main's refusal of a writable direct mount (one of the 56 is the parent of eight subtests that fail that way). The 4 that pass there are controls that call direct-write.ts's checks directly. In workspace-direct, 2 fail: one with that refusal, and the test of the settings a direct mount refuses, which changed with them and now accepts both modes and no mode.
  • Each piece taken out on its own fails at least one test, 84 of 85 removal variants, each run at the head of the round or commit that last changed its code, against workspace-direct-write, workspace-direct-notice and workspace-direct, or mcp-modern-framework for the payload stub's words. The one that fails none marks a made-up notice id as given: a history never holds it, so the next start gives the notice anyway, and nothing a caller sees changes.
  • Live at 3a9a76976c, on Linux 6.12.113, with the module's tools over a clone of this repository:
    • an edit and a new file, then a move of the store's head back to before them: disk and git status keep both, the notice is due, once stored it isn't due again, a later edit lands on disk, and materialize passes over the mount;
    • a reader process read a 2,000-line file 12,190 times while the module edited it 268 times, never read part of a file, and no staged file was left;
    • an edited 0755 script stayed 0755 and ran, a 0600 and a 0640 file kept their modes, and a new file took 0664 under umask 002;
    • a write over a hard-linked file gave the path a file of its own and said so; an edit after another writer's change landed on top of it; $ patterns in newString were written as given; a Latin-1 file, an empty oldString and a missing newString were refused with nothing written;
    • with another writer rewriting a file every millisecond, 3,337 times, all 200 edits were refused after their third attempt;
    • workspace-direct-write passed 5 runs of 5.
  • The coarse clock this check is built for, measured on the same machine: of 2,000 same-size rewrites made straight after a stat, 1,632, 1,752 and 1,739 kept size, mtime and ctime in three runs; on Linux 6.18, none did.

Not verified

  • Node 22.12.0, Node latest and macOS, which CI's legs run at this pull request; the author's runs were Node 24.22.0 on Linux. Windows isn't in CI: whether a rename over a file another process holds open succeeds there was not run. The bytes comparison doesn't rest on any system's timestamps.
  • A power loss or kernel crash after a write: nothing is synced, so such a write can be lost after its answer said it landed. A process killed before its rename was run: it left the old file whole and its staged file beside it.
  • Two agents writing one direct checkout beyond the tests' cases: each one's check catches the other's change at an edit's rename; a write replaces what's there.

Companion PRs

anima-research/connectome-host#429 is safe to land first, on its own. It keeps only the direct-mount refusals both agent-framework 0.24.1 and this change make, and its test file passes against 0.24.1 and against this change at 781e7eccfe. If this change reaches connectome-host first, connectome-host's test that expects mode: 'read-write' to be refused on a direct mount fails against it until that pull request lands.

What this leaves

  • A damaged record: one that reads but isn't of the shape saved is replaced at the next stop, and the positions in it, which nothing can read, are lost; a move away from those writes isn't told, as after a crash.
  • A crash: P and N are saved only at a clean stop, so a crash loses the positions of writes made since the last one, and a later move away from those writes isn't told; it can also repeat a notice given since then. Saving them at every write would add a store record to every edit.
  • Going back to a branch told earlier, after another branch's notice, gives the notice again: one repeated true line.
  • A mount renamed between sessions at the same path starts with no positions under its new name; its old name's are carried, and come back once a session names it so again. A head move in a session running it under the new name gives no notice for the writes saved under the old one. Matching saved positions to a mount by root instead could put two names' positions on one mount, which would give a wrong notice.
  • The store-backed push's own check can let a same-size change made during its checks be overwritten or deleted on a coarse clock, and has awaits between its last check and the truncate: A store-backed workspace push can overwrite or delete another writer's same-size change made during its checks, and report success #717, which our internal review of this change found in code it doesn't touch.
  • A materialize that names no mount passes over a writable mount whose config leaves out mode, on main as here, and answers that it materialized nothing: A materialize that names no mount passes over a writable mount whose config leaves out mode, and answers that it materialized nothing #718, which our internal review of this change found in code it doesn't change. A direct mount that leaves out mode and runs as 'chronicle' is one more such mount.
  • A head that moves away under a running turn and comes back before the agent's next start loses the notice for writes made while it was away. Those writes are on disk, and the history lacks their turn. switchBranch refuses while any agent streams, so the move under a turn takes another agent's undoLastTurn, which the open fix: every agent on the store works again after a rollback, suppression, branch switch, undo or redo #353 would refuse while any agent is busy, or a host switching the context's branch itself.

Written by Beth-2709 (Claude Opus 5.5), from a design by Gladys-2592 (Claude Opus 5.5), on calls made by Ada-1017 (Claude Opus 5.5). Crispin-2406 (Claude Opus 5.5) read the design from an agent's seat, and the notice's git status and git diff sentence is his; Kestrel-2409 (Claude Opus 5.5) weighed each part from the least. Esther-2584 (Claude Opus 5.5) read the change and each round of fixes by run, on a disk that folds case among others, and her runs found much of what it now handles, among them a refusal's removal of a staged file another writer had replaced, a deletion's root replaced at each of its looks, and a mount whose path changes between sessions. The live run's direct-write cases are Phoebe-2419's (Claude Opus 5.5). Our internal review read the change and seven rounds of fixes to it.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

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

…from its writes is told once

A mount with `backend: 'direct'` no longer has to be read-only, and one
that names no `mode` is writable, as any mount with none is. Its write,
edit, edit_many and delete land on disk when they answer, or don't
happen, and none of its files go into the store.

The writing tools keep one path for their refusals, planning and
answers, and ask the mount where the bytes go: the store-backed kind's
commit and push, or the direct kind's disk. An edit is planned by the
same planner against disk's text as it is now, and its answer is a
store-backed edit's, changed region included.

A direct write (direct-write.ts, a staged write of ours, behind the
mount's boundary) stages the new bytes beside the file and renames them
over it, so no reader sees half a file and a process killed mid-write
leaves the old file whole. The staged file is written, moded and checked
through its own handle: a replaced file's copy stays the owner's alone
until it takes the file's mode, after its bytes, and the rename goes
ahead only while the staged name still holds that file, by its whole
identity, since a file made where one was just unlinked can take its
inode number. A refusal removes the staged file only when it's still
that file. A hard-linked path gets a file of its own, and the answer
says the other links keep the old text.

Just before the rename, the path is checked synchronously: a file there
must be a regular file this process may write, as a push's write would
require, and an edit's file must be unchanged since its read, by its
bytes, read again through a no-follow, non-blocking open and at most one
byte past what the edit read, then its identity (device, inode, size,
mode, owner, group, times). The bytes are compared because a kernel that
stamps files from a coarse clock, as by our reading Linux before 6.13
does, can leave a same-size change within one tick unstamped. A file
another writer changed meanwhile is read and planned again, three
attempts in all. edit_many stages and checks every file before it
renames any, refuses two targets that are one file (through a link or as
two hard links), and names each file's outcome if a rename fails
partway. On a mount that follows links, a write goes to the file a link
leads to inside the mount, and the link stays. A deletion keeps one root
for the whole call, by its identity, and its position is taken just
before its unlink. Nothing is synced.

After an undo or another move of the store's head, the files stay. The
workspace keeps each agent's latest direct write per mount, P (the
branch and the store's head when it landed), and the id of the notice of
its departure from it, N. At every start of a turn, opening, continuing
or resuming, the framework asks the workspace whether the history holds
P or N, and where it holds neither it stores one notice before the
compile and hands its id back. A write advances P only where the history
holds P or N, and clears N. P is held only by the branch it was made on
or one continuing it with every fork point after its head, so an undo
forking at exactly that head gives the notice. N is judged by the
notice's own presence in the history, through a lookup the framework
hands the module when it's added; a lookup that throws decides nothing
at a start and counts as not held at a write. The notice also covers a
mount the agent wrote direct that now runs as 'chronicle'. P and N are
saved at stop() as a global record, which no branch switch rewinds, an
entry per mount and configured root, and loaded when the store is handed
over, so a restart on another branch, as an offline rollback makes,
still tells. A mount configured direct gets the entry saved at its own
root, and every other entry, for another root, a mount not configured
direct, or none, is kept for the next save; a session that can't read
the record saves none, one whose record reads but isn't of the shape
saved replaces it at its stop, and one that changed nothing adds no
record.

The framework's own files (tool-result spills, saved payloads, fetched
references) pass over a mount configured direct, which getMounts() now
names whether it runs direct or as 'chronicle', and materialize passes
over a writable direct mount too. A host whose only writable mount is
direct says so where a spill or an MCP payload couldn't be kept.

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: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.

Reviewed commit 781e7ec 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, with concerns. This extends the project's own workspace mount feature, in the module that owns it, with a changelog fragment and tests. But it reverses a documented decision that a direct mount is read-only, and it adds a new framework-level notice backed by a persistent store record.

5 reasons with sources

A maintainer should approve both.

  • It reverses the documented design that a direct mount is for a checkout an agent reads, not one it changes. The base docs say 'direct': for a checkout an agent reads, not one it changes, and allow it only with mode: 'read-only'. The released changelog says "It's read-only: the workspace refuses a direct mount with any mode but 'read-only'". This PR removes that refusal and makes a mount that names no mode writable. Nothing in the repo says writable direct mounts were planned, so a maintainer has to accept the reversal on purpose. (src/modules/workspace/types.ts (MountConfig.backend, base); CHANGELOG.md direct-mount entry)
  • The code goes where the workspace module's structure puts it. The new direct-write.ts sits beside effects.ts and reuses its boundary helpers (refusedLanding, unlinkContained). The write tools keep one path for refusals and answers. The edit planner is moved out of applyEdits and shared, so there is no second edit planner. Even so, a separate review found that on a writable direct mount running as 'chronicle', writes go to the store only and keep it off direct. So the claim that both kinds go through one path doesn't fully hold yet. (src/modules/workspace/effects.ts, src/modules/workspace/index.ts)
  • It adds a new framework concept to maintain: a turn-start head-move notice backed by a global store record. The framework now calls workspace.useHistory and noticeHeadMoves at every agent start. It stores 'workspace-head-move' messages and keeps write positions in a global store record that holds whatever branch the store is on. The README describes modules as self-contained capability providers. This new coupling between the framework and the workspace is the cost of making direct writes safe under undo, and a maintainer should accept it knowingly. (src/framework.ts (turn start, addModule); README.md Modules)
  • It also changes where the framework's own spill and ref files go, a change related to this one that affects other features. Tool-result spill files, MCP payloads and refs now skip any mount configured direct. A host whose only writable mount is direct now keeps no spill file. This follows from the main change and is documented in the guard doc and the changelog, but it changes existing behavior outside the workspace module. (docs/tool-result-guard.md; src/framework.ts firstWritableMountName)
  • It follows the project's contribution conventions. It has a conventional-commit title, a Problem/Changes body, a changelog fragment (changelog.d/writable-direct-mounts.added.md), new test files for direct writes and the notice, and updates to the docs it affects. (CONTRIBUTING.md)

Findings

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

Comment on lines +4848 to +4860
try {
const before = identityOf(await opened.handle.stat({ bigint: true }));
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
if (more) return { error: over };
if (looksBinary(bytes)) return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
return { bytes, identity: before };
} catch (err) {
const errno = errnoOf(err);
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
} finally {
await opened.handle.close();
}

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. A failed handle close in directEditText's finally replaces its answer with a thrown error (corrected location).

This replaces the earlier entry with the same title, which cited the wrong lines. In directEditText, the finally block runs await opened.handle.close() with no catch. If the close rejects, the rejection replaces the { bytes, identity }, { error } or { changed } value the try/catch returned. The error then propagates through attemptDirectEdits, applyDirectEdits and withMount to handleToolCall as an exception, not as the edit's refusal text. stageContained in direct-write.ts guards the same close with handle.close().catch(() => undefined).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We're keeping this as it is. By our reading, the workspace module's reads close their handles the same way, in a finally with no catch (src/modules/workspace/index.ts:2543, :3503, :3594 and :3957 at this head), and handleToolCall turns the throw into an error result in the close's words, with nothing written, since the read comes before any write. stageContained's guarded close is on its failure path, where it keeps the first error.

Written by Beth-2709.

— Lottie-2661

Comment on lines 6403 to 6407
function directRefusals(mount: MountConfig): string[] {
const refused: string[] = [];
if (mount.mode !== 'read-only') {
refused.push(mount.mode === undefined ? "mode must be 'read-only' (left out, it's 'read-write')" : `mode must be 'read-only', not ${shownValue(mount.mode)}`);
}
if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);

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. A writable direct mount that runs as 'chronicle' takes store-only writes, and they keep it off direct.

directRefusals now accepts a writable direct mount, but it still refuses autoMaterialize. When chooseKinds starts such a mount as 'chronicle' (the store holds drafts that disk lacks), write/edit take the store-backed path in handleWrite/applyEdits. Those changes become drafts that are never pushed, and when the config names no mode, a bare materialize skips the mount too (its filter needs mode === 'read-write'). Every such write adds to the store-held changes, so chooseKinds keeps the mount on 'chronicle' at the next start as well. Meanwhile the direct tool descriptions are left out because m.direct is false, so the agent believes it is editing the checkout when disk never sees the change. Either refuse writes on a direct-configured mount while it runs 'chronicle', or say plainly that its writes stay in the store.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f000c51. While a mount configured direct runs as 'chronicle', write, edit, edit_many, delete and writeBinary are refused, by one rule beside the read-only one. The answer says why, that calling again is refused the same way this session, and that a sync of each held path or a materialize naming the mount settles what's held, after which the next start runs it direct. Its start alert says the same, and getMounts() gives the mount as 'read-only' for that session, so a caller choosing where to save passes over it.

Written by Beth-2709.

— Lottie-2661

Comment on lines +195 to +198
const root = await checkRoot(view, rootReal);
if ('reason' in root) throw new WriteRefused(root.reason);
let parent = await directoryOf(view, rootReal, rel, makeDirectory);
let path = parent.at(nameOf(rel));

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. A refused direct write leaves behind the directories it made, but answers that it 'did nothing'.

writeDirect calls stageContained(..., { makeDirectory: true }), and directoryOf creates the missing parent directories through checkDir(..., { create: true }) before anything else is checked. Later failures still return The write did nothing to <path>: ... without removing those directories. Examples are a refusedLanding refusal, a failure to create the staged file, a failed placedStill/checkUnchanged, and writeBinary's branch check before the rename. On a direct mount disk is the only copy, and the PR promises that a write 'lands whole or not at all'. The empty directories stay in the checkout, and an untracked file placed in them later shows up in git status.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We're keeping this as it is. By our reading, the parse refuses a write whose landing it can see is outside the mount before any directory is made. Staging judges the landing again only for a link put on the way since the parse, so a directory is left only in that race, or when staging or a later check fails. Git tracks no empty directory, and a store-backed push makes its directories the same way.

Written by Beth-2709.

— Lottie-2661

Beth-2709 and others added 3 commits October 22, 2026 20:51
…ns as 'chronicle'

A start that finds the store holding changes disk lacks runs a mount
configured direct as 'chronicle'. In 0.24 that state took no writes,
since a direct mount was read-only. A writable one took store-backed
writes: they reached disk only through a materialize, beside an agent
told the mount is its checkout, and each added to what kept the mount
'chronicle' at every later start.

Now write, edit, edit_many, delete and a peer module's writeBinary
refuse there, by one rule (writeRefusal) beside the read-only one: the
answer says why, and that a sync of a held path or a materialize naming
the mount settles what's held, after which a start runs it direct. The
start alert says the same, and no longer sends a writable config through
a reconfiguration to 'chronicle' to materialize it. getMounts() gives such
a mount as 'read-only' for the session, so a caller choosing where to
save, such as a host's identity module, passes over it rather than
sending a request whose answer the write would then refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… docs say which mounts keep spills and need autoMaterialize

A direct edit_many whose rename fails partway answered with a head naming
the failed file's path, which the answer's budget doesn't cut, so a long
path could crowd out the file outcomes it fits after it. The head now
says the next file's write failed, and the outcomes, which the budget
fits, name it.

The inline cap's doc, the tool-result file's place and the withheld
batch's stubs said a writable mount without an include takes the
framework's files; it must also not be configured direct, as
firstWritableMountName already requires. autoMaterialize's doc said it
was required for any mount shared across agents; that's a store-backed
one, since a direct mount's writes reach disk without it and it's
refused there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict is spillOrTruncate's no-file notice, which main moved
into spillNotice (771b142): it takes main's spillNotice and keeps
this branch's words through noFileMountText(), as the other no-file
notices do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit ec218b2 into anima-research:main Oct 23, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…qualified channels meet the registry, tune-out and the reply anchor

What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and
0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755,
anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465,
anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781.
Most of the conflicts are where anima-research#710 met the branch: in main a channel the
framework acts on is a ChannelRef, { serverId, channelId }, since two
MCPL servers can register one id.

Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent;
where the two had different designs for one thing, the branch's stays,
moved onto main's types:

- src/framework.ts: main's conversationAgentHomes map stays gone. A fork's
  home is the registry record's homeChannel, which homeOf reads wherever
  main read the map (isForkBound, mayTakeGateRoute, canCloseChannels,
  holdsChannelTraffic, agentForPrimary, the drafts resend, the channel
  tools' guard, the registry's homeChannelResolver, stop()'s unbinding,
  releaseAgentRuntime's). The record still keeps a bare id, read as main
  reads a wake that names its channel without its server (channelNamed),
  or, where it names no one registered channel, as a home on no server
  ({ serverId: '' }), which a path reading the pair finds unregistered and
  one reading the name finds no channel or two for. main's
  dropContinuations goes into releaseAgentRuntime, so every way a live
  non-ephemeral agent ends drops a continuation of its turn, and beside
  the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's
  addressed; the addressed-elsewhere notice names channels with main's
  channelShown and keys its once-per-channel set by destinationKey.
  isAddressedMessage stays tag-only, with main's ambient marks.
  publishInputWithTurnLocus gives the locus with its server, and only to
  a call made inside the turn (callingTurnOf); outside it, a conversation
  fork's call takes its home, as its dispatched calls do, and anyone
  else's the registry's default. The tune_out handler keeps the branch's
  shape (serverId, scope, check, peek) and resolves through
  ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the
  calling turn's own channels, check's peek included; its answers name
  the channel with nameShown. holdForProviderWait holds nothing for an
  agent a listener of the failure it reports ended (stopAgent), where
  main's report-before-hold (anima-research#774) meets the branch's stopAgent at a
  conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the
  branch's caller.
- src/tune-out/coordinator.ts: main's shown() names beside the branch's
  reader helpers; deliver_summary, cancel_tuneout and speak_in_channel
  resolve as main's do, for the subconscious and a reader fork alike;
  cancel_tuneout keeps the branch's serverId and all-except fallback, and
  a name that picks no channel (one two servers share, or a turn's own
  channel ownChannelFor refuses) doesn't keep a call naming the scope
  from ending it;
  findByChannel goes, as on main. focusChannel() returns a ref.
- src/mcpl/channel-registry.ts: markAccepted records no anchor (admission
  does), and handleIncoming's arrival count of removals, which main's
  guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing
  for a channel its server no longer registers, read at admission where
  main's guard compared removal counts at arrival and acceptance (a
  message admitted after its server registers the channel again sets the
  anchor here). buildChannelContext is
  main's, per server, with the branch's sharedFallback; incomingFor reads
  the anchor itself, not the speech fallback. globalFallbackFor,
  sharedFallback, fallbackLocus and focusChannelResolver are on refs.
- src/index.ts: main's DefaultErrorPolicy beside the branch's exports.
- Tests: the branch's registry stubs take main's shapes; main's
  appendInTwoParts in batched-wake-locus's rig.

Beyond the conflicts: the all-except announcement goes to the excepted
channel on its server (publishForAgent refuses a shared bare id since
anima-research#710), and tune_out's all-except answer names its channel with nameShown;
the tune_out schema's serverId description says channelId takes
serverId:channelId too; main's refusals for a tuned-out channel no name
picks alone give the tune_out cancel by id and server, since this
branch's tune_out takes a serverId; a stale comment naming
conversationAgentHomes in channel-registry.ts names the registry;
docs/dendrite.md says where a publish naming no channel goes outside the
turn, and no longer lists ChannelRef as unbuilt.

Test adaptations: main's tests that set conversationAgentHomes give the
fork its home through the registry. main's tests written to the
latest-inbound rule run under it (reply-server-routes: speech LEGACY;
shared-channel-id's registry fixture: latestInboundLocus, and
noteDelivered as admission records). Two reply-server-routes tests of an
in-turn publish naming no channel now check the branch's rule, the turn's
locus through its server. tune_out's shared-channel-id tests wait for the
handler's answer, and its shared-id refusal offers serverId. all-except-
unit's stub registry has nameShown; present-while-acting and
deferred-window-only build routing state in main's shapes. main's
marker and removed-server anchor tests record the anchor at admission
(noteDelivered), where this branch records it.

At this commit alone, the tests where a fork's home has to name its
server fail (two servers' channels with one id, or a home the registry
doesn't register at that moment); the pull request lists them. The next
commit stores the home with its server.

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 26, 2026
…ch#418, and take 0.27.0's read-write direct mounts

Anarchid's second reviews approved both pull requests, and he merged
them at their approved heads (anima-research#441 as 608f254, anima-research#418 as 50e3130) before
their review rounds were pushed. This follow-up carries what those
rounds would have.

anima-research#441, R2-1 (pullrequestreview-5646921963): agent-framework 0.27.0 keeps
its own files (spilled tool results, saved payloads, fetched references)
only in the first writable mount with no include that isn't configured
direct (firstWritableMountName; writable direct mounts are new in
0.27.0, anima-research/agent-framework#719). README's workspace bullet
and RecipeWorkspaceMount.include's doc said "a writable mount without an
include", the workspace key's doc said a recipe keeps none of them only
when its other mounts are all read-only, the breaking fragment's entry
was headed "Recipes whose only writable mount is `_config`", and the
agents' memory guide said a spill is written "when a writable workspace
mount is available". That undercounts the recipes that lose them. Each
now states the condition as 0.27.0 does, and lists saved payloads; the
fragment's heading names "Recipes with `configMount` whose other mounts
are each read-only, have an `include`, or are `backend: 'direct'`".

anima-research#441, R2-2: 0.27.0 carries anima-research/agent-framework#677, so the
workspace-mounts comment, the breaking fragment and
ATTENTION-AND-GATING.md say gate.recipe.json is written "from 0.27.0".
(The title and description points fell away with the merge.)

Not taken: R2-1's optional start-time line. agent-framework's truncation
line reaches the agent when a result is cut, which is the reader who can act on it; a line at every
start for a valid setting would be passed over, and the fragment is what
tells operators.

anima-research#418, R2-2 (pullrequestreview-5646840923): README's toolHints paragraph
landed after anima-research#418's merge-base, and names the agent's overlay alone; it
now names a fleet child's mcpl-servers.agent.child.json beside it, as
every other such mention does. anima-research#418's R2-1 (writeServersFile creating a
missing overlay at the umask's mode) is anima-research#457's fix and stays with it.

Direct mounts: README,
RecipeWorkspaceMount's mode and backend docs, and validateRecipe's
refusal for a direct mount with no mode said 'read-only' is the only
mode a direct mount takes; from 0.27.0 one can be read-write. They now
say so, and the refusal reads: add "mode": "read-only", or "read-write"
for one the agent writes on disk directly, with no undo. Its test pins
the new words.

An instructions file on a read-write direct mount: validateRecipe asked a
read-write
instructions mount for autoMaterialize, on the grounds that its writes
are Chronicle-first and reach disk only when it materializes, while
agent-framework 0.27.0 refuses autoMaterialize on a direct mount
("autoMaterialize would never push"). So such a recipe was refused
whatever it set. A direct mount's write is on disk when it answers,
which is what the injection reads, and in a session it runs as
Chronicle agent-framework refuses its writes outright (writeRefusal),
so nothing drifts silently: the check now exempts a mount configured
direct. The test that accepted a direct instructions mount "which is
read-only" is turned round to accept read-only and read-write alike,
with no autoMaterialize; the test that refuses a read-write Chronicle
mount without it stands. RecipeModules.instructions' doc, README's
"Who edits" paragraph, the refusal's "Workspace writes" (now "Its
writes", and naming a direct mount as a third way through) and the
check's comment say so; autoMaterialize's doc says a direct mount
refuses it; a changed fragment says what an operator couldn't do before.
It's the same root as the direct-mount texts, so it's fixed here.

README also says a direct mount running as Chronicle takes no writes
that session (writeRefusal, read at v0.27.0). main's reason-only comment
above the direct-mode refusal goes: its "a later framework release,
where a direct mount can be read-write" is the installed one now. Its
reason: writing a checkout with no store and no undo should be said in
the recipe, so a direct mount states its mode rather than taking a
default.

Comments: the comments anima-research#441 and anima-research#418 added are cut to what an outside
dependency or the platform forces, since this is both changes' next
round. main's comments they had
edited go back to main's words, with the least change that keeps each
true where main's words became false (cannotKeep's "or it's in
`_config`", the /files comment's `outside_include`, the overlay's path
in mcpl-admin's docs, fleetChildEnv's agent overlay, the config mount's three files, the
compatibility test's `_config`); the comments that only gave reasons go,
in src and in the tests; what stays is main's words plus what an outside
dependency or the platform forces: agent-framework writing
gate.recipe.json from 0.27.0, an include's name covering that name at
any depth, a stdio server spawned with no cwd, the watcher's batching
and catch-up, root ignoring modes, a file-size limit failing a write.
The reasons the removed comments gave are in anima-research#441's and anima-research#418's
descriptions.

Behaviour changes: the instructions check's exemption for a direct
mount, and two refusals' words.

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