Skip to content

feat(workspace): a read-only direct mount, reading disk as it is - #564

Merged
nissa-seru merged 18 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-direct-mount
Oct 20, 2026
Merged

nissa-seru merged 18 commits into
anima-research:mainfrom
nissa-seru:feat/workspace-direct-mount

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A workspace mount can now be direct: a mount whose config sets backend: 'direct', for a checkout an agent reads while it works in another. Its read, read_image, ls, glob and grep read the files on disk as they are when they run. No scan, watcher or pass runs on it, so no call waits behind one, and none of its files go into the store. A direct mount is read-only. A mount that names no kind is the store-backed kind it has always been (backend: 'chronicle'), and works as it does today.

Problem

A store-backed mount reads each file through the store: a pass observes disk under the three-way rule, takes in what changed, and the tools answer from what it stored. That serves a checkout the agent writes in, where the store keeps its drafts. For a checkout it only reads, such as the repositories beside the one it's coding in, the store has nothing to keep, and its passes cost what they always cost. A run on Linux, on a host at a median load of 57 on its 12 CPUs, which lengthens every time here, measured three such costs, each beside a direct mount over the same files (its setup is under Tests):

  • A read can miss a shell's change. With watch: 'never', connectome-host's default for a recipe's mounts, read gives a file the workspace already holds from the store. After a shell appended a line to such a file, read showed the old text in 6 of 6 rounds. On a direct mount it showed the new line in 6 of 6.
  • A call can wait behind a scan. With watch: 'on-agent-action', each tool batch is followed by a scan of the mount, and a call on the mount waits for it. On one mount over six repositories and fourteen worktrees, 6,058 files, a read right after a batch took a median 23.2 s (14.5 to 33.0 s). Unwatched, it took 146 ms, and direct, 16 ms.
  • A first search takes in everything it walks. Over seven mounts of the same files, the first grep of the whole workspace took a median 39.6 s and wrote 18,302 records to the store. Direct, it took 2.7 s and wrote none. Later greps took 3.5 s and 2.2 s, and both kinds found the same 70 matches.

Changes

In src/modules/workspace/types.ts:

  • MountConfig.backend?: 'chronicle' | 'direct', documented with what a direct mount does and refuses, and when a mount configured direct runs as 'chronicle'.
  • MountConfig.ignore's doc gives the default list of a mount configured direct, and the rule for a path a tool names.
  • MountConfig.watch's doc for 'never' says a read observes only a path the store doesn't hold, as backend's doc of a store-backed read now says; it said lazy reads observe the paths they show.
  • MountState.direct: whether the mount runs direct.

In src/modules/workspace/index.ts, each read tool keeps one path for its path rules, limits, refusals and rendering, and asks the mount's kind for what it reads:

  • read: bytesOf gives a file's bytes. On a store-backed mount that's the store's copy, taken in from disk first where the store holds none, as before. On a direct mount it's bytesOnDisk: disk's bytes through openContainedFile, at most the mount's maxFileSize, refusing a binary file and one over the limit, and saying why with its errno where it couldn't open one.
  • read_image: tries the store's copy, then disk's, as before. A direct mount holds no copy, so its image is disk's. One renderer, imageResult, answers for both.
  • ls and glob: listingOf gives a scope's files and directories. On a store-backed mount that's the pass (listingFromStore, moved from handleLs and handleGlob). On a direct mount it's listingFromDisk: diskWalk's walk, each entry observed with observePath, 16 at a time.
  • grep: textsFromStore (moved from handleGrep unchanged) or textsFromDisk hands each text to search to the one search and result code, in path order. textsFromDisk reads each file with readBytes, 16 at a time, and lists what it doesn't search under skipped.
  • walkStart reads a direct mount through its own view, and there stops only before another mount's directory.
  • A direct mount's view (diskView) has no root identity, and readRoot checks a direct mount's root against none, so neither reads the disk-agreement evidence. A direct read that finds its root replaced as it opens the file is refused in words of its own (rootMoved), since reading again reads the directory there now.
  • parsePath gives a direct mount's paths disk's spelling alone, since it holds no workspace entries to keep a spelling from. It resolves a direct mount's root once, and returns it with the nesting there (ParsedPath). Its respelling reads disk beneath that root, and keeps the caller's spelling past a nested mount's directory by that nesting. The checks of the path after it read disk beneath that root and judge by that nesting too: parseFilePath's two, which the file operations parse with, the include's scope check (refusesAsOutside), and grep's check of a path it's given (namesFile). A link on a path's way is read beneath the root the check resolved (linkReadError), so a refusal names no target by another root's nesting.
  • A direct mount whose root has moved since the last start, as when a link it's configured through is repointed, nests each other mount as a start would nest it there now (movedNesting), with its own include. A start and a moved root place a mount with one function (placementOf), from where its real root and the directories above its configured path lie on disk: as they lie now for a mount configured at the moved mount's path or beneath it, and as the last start found them for any other mount. A check judges which mount a path is by the root it resolved. A tool's parse resolves the root once, for its respelling and its checks of the path it's given. A read and a walk each resolve it for themselves, judge the include and the nesting again beneath it, and read beneath it: a walk and grep's reads open their files beneath the root they resolved, and a read refuses a file once its root is no longer the directory it checked. So, by our reading, a root repointed during a call can change which answer or refusal a path gets, but nothing a read or a walk serves is a file the include leaves out, nothing a walk serves lies in another mount's directory as its own nesting places it, and nothing a read serves lies in the directory of a mount its own nesting claims by real root. Whoever writes the disk can race a read in two ways, as a rename during a call can on any mount. On a disk that folds names, a read judges the include by the path as its parse spelled it, so a root repointed between the two can have it serve a file the new root spells otherwise. And a mount nested by its directories alone, since its real root holds the root, is judged by the parse's nesting, so a root repointed between a read's parse and its open can have it serve a path in that mount's directory. A root that's gone is no move: the parse's checks keep the start's placement, and its read says it's unavailable.
  • A mount's include holds on a direct mount as on any other, through its view and the checks both kinds share: its walks pass over what the include leaves out, its listings and greps show none of it, and its file tools and the host's reads refuse it. A direct mount has no history, so grep's choice of file or directory (namesFile) and the include's scope check (refusesAsOutside) read disk alone there, whatever a store-backed run of it left in the store. heldChanges counts only what the include covers, as status does.
  • The constructor refuses a backend other than the two. On a direct mount it refuses a mode other than 'read-only', and watch other than 'never', watchDebounceMs, watchRootPollMs, a wakeOnChange that would wake, or autoMaterialize: true (directRefusals).
  • initStore registers no states for a direct mount, and opens disk-agreement evidence only for mounts that have states. chooseKinds decides there, and again in ensureRunning at each start, whether a mount configured direct runs direct: where the store has its states from a store-backed run, it reads what the store holds there that disk lacks (heldChanges, which status now counts its pending changes with too), and a mount that holds any runs store-backed from that start. chooseKinds decides every mount before it sets any, and a mount configured direct that ran as 'chronicle' runs direct only once it has decided nothing is held there. ensureRunning says so (sayNotDirect) on stderr, and as one ops alert: first what's happening and the two ways to start such a mount direct, with a sync of its path for an intent the workspace can't read where one is held, then every such mount and what it holds, counting such an intent as one, not as a change.
  • A mount configured direct watches nothing, and walks by the direct kind's default ignore list where its config names none, whichever kind it runs as (watchOf, read by ensureRunning, the pass's catch-up flag, status and onToolBatchComplete; ignoreOf).
  • ls with no path, and status, give a mount configured direct its backend, as it runs this session, and the ignore list it walks by. A direct mount's status gives only its path, mode, backend and ignore, since it keeps nothing to count.
  • sync lists a direct mount under skipped.
  • readBinary reads a direct mount's disk, through bytesFromDisk, factored out of readBinaryFromDisk unchanged.
  • getTools adds a sentence on the direct kind to ls, glob, grep and sync where a mount runs direct.
  • inOrder runs a direct grep's reads and a direct listing's observations, at most 16 at a time, handing each result on in order and letting it go.

In src/modules/workspace/observe.ts, readBytes reads a file through openContained, as readPath does, at most a limit of it, hashing none. What its open refuses it names as a listing names it, such as a link to a directory, except a regular file it couldn't open, which keeps the open's reason.

The changelog fragment is changelog.d/workspace-direct-mount.added.md.

What each part is for, and what was cut

  • One path per tool, with two sources. A tool's rules, limits, refusals and rendering are written once, so a later change to a tool reaches both kinds. A second set of tools for the direct kind would make every such change twice.
  • bytesOnDisk refusing a binary file and one over the size limit. A store-backed mount doesn't take them in from disk, and its read refuses one it never took in. The words say which it is.
  • listingFromDisk observing each entry with lstat. It gives a file's size, which a listing shows, and for a link or another entry the mount doesn't take, the reason a store-backed listing gives. A file gone since the walk listed it is left out.
  • No state on a direct mount's entries, and version: 'disk' on its grep results: there's no store copy for a state to describe.
  • Walking a path a tool names, whatever the ignore list covers there. The list keeps whole-mount walks out of trees nobody asked for. On a store-backed mount, a sync that names such a path takes it in, and listings then show it. A direct mount has no sync, so a listing, glob or grep that names the path walks it, and the rest of the list applies beneath it.
  • The default ignore list, for a mount configured direct whichever kind it runs as. Without one, a whole-mount walk of a checkout spends its cap on node_modules, build output and .git, and a mount running store-backed would take them into the store.
  • No root identity on a direct mount. It runs no pass, so it never records its root's identity, and it has no acceptRoot to accept a new one. Checking against an identity a store-backed run recorded would refuse every read under a re-cloned checkout until its config changed.
  • Nesting a direct mount by the root each check resolves, where that root has moved. With no check against an earlier root, which mounts lie beneath it has to come from the root the check uses, or a root repointed through a link reads another mount's directory as its own. Worked out per check, it resolves nothing but the moved mount's own path and directories beneath its root, and changes nothing shared. It places each mount with the function a start places it with (placementOf), so the rule a start follows is written once: a moved root nests as a start there would, by construction, wherever it knows what a start would know. So nothing orders placements or refreshes watchers, and a share that doesn't answer holds only its own mount's calls, unless a configured mount's directory beneath the moved root is a link into that share, where that mount's files lie. What it leaves to the next start: a mount configured elsewhere whose own root has moved too, as when a link on its configured path is repointed, the moved mount's own reached through another link among them, and the other mounts' own placements. So a direct root moved into another mount's directory stays that mount's too, as a nested mount's root moved while running stays placed.
  • A direct mount's include, as on any mount. A narrowed read-only checkout is just where an include earns its place, and refusing one on a direct mount would be a refusal to lift later.
  • Reading no history on a direct mount. What a store-backed run of it left can hold a file disk no longer has, or call a directory a file, and a direct mount reads disk as it is.
  • Counting only what the include covers among the changes held. No push writes what lies outside it, so holding the mount store-backed for it would hold it there with nothing that could settle it.
  • The parse resolving a direct mount's root once, for its respelling and every check of the path after it, each reading disk beneath that root and judging by its nesting, a link on the way included: holderOf, refuseNotHeld, landingRefusal, nestedDepth, refusesAsOutside and namesFile take the parse's nesting or root. feat(workspace): a mount's include names the files it holds, and is its boundary #536's respelling and scope check would otherwise read the last start's nesting where the file operations read the moved one, and checks that each resolved their own could part in one call, so that a refusal could name a link's target at one root by another root's nesting.
  • A direct mount's reads and walks reading beneath the root each resolved: its view's root is the root it resolved, a read's link checks read beneath the root it opens under, and a link's refusal reads where the link leads beneath the root the check that found it resolved (linkReadError). Otherwise a root repointed mid-call has a walk or grep open a file its checks never judged, or a refusal name a link's target at one root by another root's nesting.
  • Comparing both kinds' answers under the same calls (alike, answersOf), for the include and for spellings. What feat(workspace): a mount's include names the files it holds, and is its boundary #536 guarantees on a store-backed mount is then checked on the direct kind as a whole, where a test per guarantee would restate each one.
  • The moved nesting carrying each mount's include. feat(workspace): a mount's include names the files it holds, and is its boundary #536's checks judge a place in a nested mount's directory by that mount's include, so without it a moved root would judge such a place by none.
  • Keeping where the last start found each mount's real root and the directories above its configured path (placedRoots, placedAbove), so a moved root places a mount configured elsewhere from what that start knew, as a start there would place it from the same facts, resolving nothing outside its own path and root.
  • Taking one start's placements per call in movedNesting, before its first await, so a start in the meantime can't give one call a nesting from two starts.
  • Only absence of a moved root keeping the start's placement. Any other failure to look at it would judge the moved root by the nesting of the root it left; where nothing is there, a read says the root is gone and there's nothing to serve.
  • One placement function for a start and a moved root (placementOf), so a moved root nests as a start does by construction, not by a second copy of the rule that each link layout would have to be checked against. A moved call resolves the directories above the moved mount's own path once, and only where a mount is configured at or beneath that path.
  • Probing whether the test temp directory folds case (FOLDS_CASE), so the tests that need such a disk run where they can fail, and skip, saying why, where they can't.
  • readBytes hashing nothing, and reading only the head of a file over the limit. A direct grep keeps nothing, so a hash buys nothing, and an oversize file needs only its first 8 KB to say what it is.
  • 16 reads at a time. Over seven mounts of 5,929 to 6,029 files, a direct grep reading 16 files at a time, with Node's default thread pool of four, took a median 0.45 to 0.94 of the store-backed grep's time in the same round, at every load tried, and one reading them in turn took 2.1 to 2.4 times it (measured on the same Linux host with Node 24.22.0, before this change was built).
  • inOrder letting each result go once handed on, so a grep holds at most 16 files' contents, not every file it has searched.
  • Running store-backed while the store holds changes disk lacks. A direct mount would show disk's copies, and the agent would lose sight of its own unfinished changes with nothing to say so. The mount runs as it did, its changes in view, and the alert names them and the two ways to start it direct.
  • Deciding at each start, and saying so in one alert. A start knows how the mounts nest, which the first decision may not, and a later start of the same module finds the changes as they are then. A decision that can't read the store leaves the mount as it was, since running direct over unfinished changes would hide them. The framework's webhook posts one alert of a kind per cooldown, and keeps only the first 346 to 349 characters of a long one and its last 115 or 116, so every such mount goes in one alert, and the words of fixed length, what's happening and the ways out, come first.
  • A mount configured direct showing its kind and list while it runs store-backed. It walks by an ignore list no config shows, so an agent reading its listings sees what the list is, and that the mount is store-backed this session.
  • Counting an intent the workspace can't read as one. It may hold a change or may not, so the alert doesn't call it one.
  • Saying to sync the path of an intent it can't read, wherever one is held. Materialize settles one only where the workspace holds a copy there, and where it holds none it names nothing, so an operator following the materialize route alone would find the mount store-backed again with no word why.
  • Evidence opened only for mounts with states, so a store gains no record for a mount that never ran store-backed.
  • Refusing settings that would never act. A direct mount set to wake on change would never wake, with nothing to say so.
  • Descriptions only where a mount runs direct. A host with no direct mount keeps its tool descriptions as they were, and its agents' prompt caches with them.
  • sync listing a direct mount under skipped, so it never says "nothing changed" of a mount it didn't look at.
  • heldChanges shared with status, so what keeps a mount from starting direct is what status counts as pending, plus intents that can't be read, which may hold either.
  • inOrder's guard against a read that fails before its turn, which would otherwise end the process as an unhandled rejection.
  • Not taken:
    • searches walking past the cap: each tool keeps its limits on both kinds, so a direct search stops at the same cap and names what it didn't reach;
    • watching a direct mount;
    • a writable direct mount;
    • a state name of its own for a direct mount's entries;
    • a MIME type on a direct mount's listed files, which would cost a read of every file's head;
    • opening each listed file to say whether it can be read: a direct listing looks with lstat, and a read of the file says why it can't;
    • an unavailable mount in place of the store-backed run, which would hide every file on it, the unfinished changes included;
    • registering store states for a direct mount;
    • a direct source as a class or file of its own: its parts are the module's own readers and walk.

What callers will notice

  • A host that sets no backend sees nothing change: each mount is store-backed, with the same tools, tool descriptions, answers and status fields.
  • A mount set backend: 'direct' answers as the changelog fragment describes:
    • entries with a size, or a note in its place for an entry it doesn't take, and no state;
    • grep results with version: 'disk', and a skipped list;
    • a path a tool names walked, whatever the ignore list covers there;
    • sync passing it over;
    • ls without a path, and status, giving its backend, as it runs this session, and ignore.
  • Building the workspace refuses a direct mount that isn't read-only or sets one of the settings above, naming the mount and each setting.
  • Tool descriptions of ls, glob, grep and sync gain a sentence about the direct kind only on a host where a mount runs direct.
  • Operators: a mount configured direct, whose store holds changes disk lacks from a store-backed run, runs as 'chronicle' from that start, unwatched. The workspace says so on stderr and through the framework's ops alerts, as kind workspace-mount-not-direct: a failures.log record, an ops:alert trace, and the webhook where CONNECTOME_OPS_WEBHOOK is set. The alert names every such mount, the ways to start one direct, and what's held there, counting an intent the workspace can't read as one, not as a change. Where such an intent is held, it says to sync that path: materialize settles one only where the workspace holds a copy there, and where it holds none, neither selects nor names it.
  • connectome-host passes a recipe's mount keys to the framework, and its MOUNT_KEY_CHECKS is typed by MountConfig's keys. connectome-host#352 names backend in MountKeyNotPassedOn, as connectome-host#335 did for include, so its typecheck takes a release that carries backend, and a recipe that gives backend is still refused as an unknown key, as before. Its mount builder defaults mode to 'read-write', which a direct mount refuses. Its web UI's workspace tree reads { path, size } from a recursive ls; a direct mount's file entries carry size, and an entry it doesn't take carries a note instead, as a store-backed listing's entries that disk couldn't be read for do. Nothing changes for connectome-host until it takes up a release that carries backend.

With other pull requests

At this head, merged with each open pull request that changes the workspace module:

The backend field and its values are the ones #271 proposes. #271 also makes direct the default, and adds writes, a watch tool and append, which this change doesn't.

Tests

86 new tests in test/workspace-direct.test.ts, each pinning a contract this change sets (two run only on a disk that folds case):

  • Reading disk as it is: a direct mount's read, glob and grep show a file, or a change to one, the moment disk has it, and nothing is recorded in the store from its start on, its after-batch hook included. read_image and readBinary read disk's bytes, and read_image never shows a copy a store-backed run stored.
  • Refusals: read refuses a binary file and one over the size limit, gives the disk reader's refusals (a missing file, a directory, a link, a link on the way, a path out of the mount), and says why it couldn't open a file. A read that finds its root replaced as it opens the file is refused, and the next read reads the directory there. Building the workspace refuses each setting that would never act, naming the mount and the setting, an omitted mode included.
  • Listings: sizes and no state, a link with why it isn't taken, a directory it couldn't list, another mount's directory with the mount to use, and the default ignore list kept out of whole walks, with no region named for it, while a named path is walked.
  • Walks: the cap holds, a search starts where its pattern names, and what it couldn't reach is named.
  • Reading files: read loads none of a file over the limit and grep only its head; a file that grows past the limit as it's read is refused; a failed read says so; a skipped file's size is what the reads found where the file changes as it's read; every handle opened is closed; a file gone between the walk and its read is neither listed nor searched; grep lists what it doesn't search, with why, names a link to a directory as its listing does, one it can't open included, and gives its results in path order with its first file's reads held until fifteen others are read; and grep and listings read 16 files at a time.
  • Spellings: a . or .. segment, an empty one and a trailing / get the same answers from a direct mount as from a store-backed one: each of them in read, some in read_image, ls, glob and grep, and three in the host's reads. A path that climbs out of the mount is refused.
  • The include: a direct mount's include holds as a store-backed mount's does under the same calls, an image it leaves out, a covered link that lands on a file it leaves out and a nested mount's own include among them; what history holds outside it keeps no mount configured direct from starting direct; and a scope is judged by disk, not by what a store-backed run left.
  • A moved root: a direct mount whose link is repointed reads another mount's directory as that mount's, in its grep, listing, read and the host's read, and its own directory again once pointed back. Moved away from a mount nested in it, it reads what's there as its own in read, the host's read, ls and grep. A mount configured beneath its path answers as a start there answers, through a link into it or one to its file too, where its directory is a link within the root or above it, and, on a disk that folds case, where it's spelled otherwise than disk spells it (macOS only). Moved into another mount's directory, it reads what's there while that mount keeps it too. A root moved between a read's parse and its open is judged by the root the read opened under, a link on the way included, and the parse judges where a path leads beneath the root it resolved, against the include too, and reads a link on its way there, naming no target another mount's include leaves out. A link on the way into a nested mount's directory is refused as that mount's, at a path only its draft has too. A nested mount's own include judges a link into its directory under a moved root, as at a start there; a root moved away from a nested mount judges a path its include leaves out by disk there, in sync and materialize too; a mount configured where the moved root now lies, through a link to it, is nested as a start there nests it; and, on a disk that folds case, a path past a nested mount's directory under a moved root keeps the caller's spelling there. A moved root resolves no other mount's root but those configured beneath its path; a root that hasn't moved keeps the start's placement; and one that's gone beneath a link keeps it too.
  • Writing: write, edit, edit_many and delete each refuse a direct mount as read-only and store nothing, and materialize names it among what it skipped.
  • What the tools say: ls without a path and status give the kind and ignore list, sync passes a direct mount over and says so, the root is read as it is now, and tool descriptions mention the direct kind only on a host with a direct mount, a host with a writable store-backed mount beside it included.
  • The switch: a mount the store holds changes on runs as 'chronicle', says so on stderr and as an ops alert, gives the workspace's copies in every tool, shows its kind and list in ls and status, and starts direct once they're given up; each kind of held change keeps it store-backed on its own, and a sync of its path settles it; a tombstone beside an entry that's still there, which status counts as no deletion, keeps no mount from starting direct, and the same tombstone holds it once its entry is gone; an intent it can't read where the workspace holds no copy is left by materialize, forced too, and settled by a sync of its path, as the alert says, wherever one is held; changes on a branch not selected at the start don't; several such mounts are named in one alert, ten changes at a time, with an intent it can't read counted as one, and the ways out come within the start of the alert a webhook keeps, however long the mounts' names; it's decided once the store is given, in either order of start and initStore, and said once; a later start of the same module decides again; a decision that can't read the store leaves the mount as it was; it walks by the direct default ignore list; and a mount that ran direct takes in what disk changed meanwhile when it runs as 'chronicle' again.
  • inOrder: results in order when their works settle last to first, at most width at once, each let go once handed on, and a work that rejects before its turn rejects in turn, with no unhandled rejection.

At this head's tree, from a clean checkout, on Linux with Node 24.22.0:

  • tsc --noEmit and the build are clean.
  • npm test: 5,568 tests, 5,524 pass, none fail, 44 skipped. main 4ce694e4, the main this change contains, run the same way here, gives 5,482, 5,440 and 42. The difference is the 86 new tests, of which the two case-folding ones are skipped here.
  • CI's three files run without --test-force-exit: 128 of 128 pass.
  • Red-first: with main's src and the new test file, 70 of the 86 tests fail, and the two case-folding ones are skipped. Fourteen pass there as controls: read_image reading disk's image, a file gone between the walk and its read, every handle a read opens being closed, the writing tools refused on a read-only mount, the four that compare a direct mount's answers with a 'chronicle' mount's, which agree there, and six placements and judgements a store-backed mount gives too (a root that hasn't moved, one that's gone beneath a link, one that now leads through a file, a mount at the moved mount's own path, a mount configured at a link that leads above the root, and a listing's judgement of a path its include leaves out). The three inOrder tests fail on main only because it lacks the export.
  • Removals: of 142 variants of the change, each run alone against the built code, 139 fail at least one of the new tests here. Two can fail only on a disk that folds case: Esther-2584 ran the case-folding tests with each on such a disk, at earlier heads of this change, and a test failed with each, and CI's macOS legs run those tests. The third has a read check the links on its way beneath its configured path instead of the root it checked. That shows only if the root is repointed and pointed back between those two steps, since once a read's root has moved, its refusal says so whatever the links are.
  • On a disk that folds case: Esther-2584 ran both case-folding tests on a WSL2 drvfs mount at the tree of c5eb97eb84, and both pass. Since then, this head changes comments, a test's title, one line of movedNesting, and how the gone-file test finds the listing's look at its file.
  • With the temp directory reached through a link, as macOS's is (/var there is a link to /private/var): the new test file passes, as it does without one.

The live run, at the tree of 4d182ebaf3, whose src this head keeps, on the same host (12 CPUs), is built on Gladys-2592's search benches. It reads one mount over six repositories and fourteen agent-framework worktrees, 6,058 files, and the same files as seven mounts, as three kinds: store-backed unwatched, store-backed with watch: 'on-agent-action', and direct. It ran six rounds at a median load of 57 (25 to 79), so its times run long against a quiet host, 222 calls, none failing, and the kinds gave the same answers. Its figures are under Problem, and beside them: a first scoped glob took 961 ms store-backed, writing 196 records, against 35 ms direct, and a filtered grep 74 ms against 39 ms.

Not verified

  • macOS and Node 20: CI runs them.
  • connectome-host with this change: not built or run.

  • Changelog fragment added: changelog.d/workspace-direct-mount.added.md

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). Phoebe-2419 (Claude Opus 5.5) measured the workspace across many repositories at once, which this change answers in part. Esther-2584 (Claude Opus 5.5) read the change and each of its fixes, and her probes and runs found much of what it now handles, among them a path's . and .. spellings, how the alert counts an intent it can't read, and how a moved root nests. She also ran the case-folding tests, and the variants only they can catch, on a disk that folds case. Nell-1783 (Claude Opus 5.5) wrote #536, whose include this change carries onto a direct mount. Cuthbert-2453 (Claude Opus 5.5), who wrote #576, read this change's merge with it. Greptile's review found that a direct mount's root, repointed through a link, read under the nesting placed at start. Nine rounds of our internal review read it. The live run is built on Gladys-2592's search benches.

Checked and opened by Fiona-2757 (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.

Beth-2709 and others added 8 commits October 19, 2026 02:15
…it is, with none of its files in the store

A mount whose config sets `backend: 'direct'` is read straight from disk:
`read`, `read_image`, `ls`, `glob` and `grep` give its files as they are
when they run, no scan, watcher or pass runs on it, and none of its
files go into the store. It's read-only. A mount that names no kind is
the store-backed kind (`'chronicle'`) and works as before.

Each read tool keeps one path for its path rules, limits, refusals and
rendering, and asks where the mount keeps what it reads:
- read: bytesOf, the store's copy as before, or bytesOnDisk, disk's bytes
  through openContainedFile, at most maxFileSize, refusing a binary file
  and one over the limit as a store-backed read refuses what it never
  stored;
- read_image: the store's copy then disk's, as before, or disk's alone,
  with one renderer (imageResult);
- ls and glob: listingOf, the store-backed pass (listingFromStore, moved
  from the handlers) or listingFromDisk, diskWalk's walk with each entry
  observed (observePath), 16 at a time: a file's size, or a note for what
  the mount doesn't take;
- grep: textsFromStore (moved unchanged) or textsFromDisk, each file read
  with readBytes (observe.ts: openContained, at most the limit, no hash),
  16 at a time in path order (inOrder), skipped with why where it's
  binary, over the limit or not read; results say version 'disk'.

A direct mount's walks are named, as a path sync's are, so a path a tool
names is walked whatever the ignore list covers there, and walkStart stops
only before another mount's directory. Its ignore list defaults to
node_modules, dist, .git, build, coverage, target, __pycache__ and .venv.
Its view has no root identity, and readRoot checks none, so it reads
beneath whatever directory is at its root, checking no evidence.

The constructor refuses a backend other than the two, and a direct mount
that isn't read-only or sets watch other than 'never', watchDebounceMs,
watchRootPollMs, a wakeOnChange that would wake, or autoMaterialize
(directRefusals). initStore registers no states for a direct mount; where
a store-backed run left them, it reads what the store holds there that
disk lacks (heldChanges, which status now counts with too), and a mount
holding any runs store-backed that session, unwatched, which ensureRunning
says as an ops alert (workspace-mount-not-direct) and on stderr, naming
the paths and the two ways to start it direct. ls with no path and status
give a direct mount's backend and ignore list; sync lists it under
skipped; readBinary reads its disk (bytesFromDisk, factored out of
readBinaryFromDisk unchanged); getTools adds a sentence on the direct
kind to ls, grep and sync only where a mount runs direct.

Designed by Gladys-2592, on Ada-1017's calls; tests in
test/workspace-direct.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s drafts spelling and anima-research#544

Two conflicts in src/modules/workspace/index.ts, each taken from both sides:
- the imports from observe.js: anima-research#547's foldsCase and WorkspaceEntries beside the
  direct mount's readers and walk;
- walkStart's doc: anima-research#547's "as parsePath spells it", with the direct
  mount's sentences on where its start stops.

One change no conflict showed: anima-research#547's parsePath spells a path with the
workspace's entries (entriesOf), which read the mount's tree and intent
states. A direct mount has neither registered, and no BranchIntents, so
entriesOf would throw on every path a direct mount's tools parse. A direct
mount holds no entries, so parsePath gives it none, and its paths take
disk's spelling alone, as diskSpelling does without entries; parsePath's
doc says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read, and one spelling per path on every mount

Answers an internal review of 6aadccb, and Esther-2584's read of it.

The switch between kinds:
- chooseKinds decides whether a mount configured direct runs direct when
  initStore gives the store, and again in ensureRunning at each start,
  which knows how the mounts nest, so a later start of the same module
  that finds no changes held starts it direct.
- One ops alert per start names every such mount, with what's happening
  and the ways out (sync a path to take disk's copy, or materialize from a
  'chronicle' read-write mount as materialize's answer directs) before the
  list of changes, which the framework's webhook may cut.
- A mount configured direct walks by the direct default ignore list where
  its config names none, whichever kind it runs as (ignoreOf by the
  configured kind, as watchOf goes; found by Esther-2584).
- DiskAgreement.open takes only mounts whose states exist, so a direct
  mount that never ran store-backed begins no evidence in the store.

The direct readers:
- inOrder lets each result go once handed on, so a grep holds at most its
  width of file contents.
- A direct read of a file it can't open says why with its errno (Esther-2584).
- readBytes names a link to a directory as a listing does (Esther-2584).
- A root replaced while a direct read runs is refused in words of its own:
  reading again reads the directory there now.

Paths, on every mount (Ada-1017's call; taken out again two commits on):
parsePathAsGiven drops '.' and empty segments and a
trailing slash, and reads '..' lexically, as its traversal check already
did, still refusing a path that leaves the mount; parsePath answers with
the spelling so made. Fragment workspace-path-one-spelling.changed.md.

Text: glob gains a direct sentence where a mount runs direct; an
omitted mode on a direct mount is refused as left out, read-write;
types.ts's backend doc names a 'chronicle' read's behaviour and an
unreadable intent among what's held; the changelog fragment drops
'never stores' and names no MIME type on listings.

Tests: inOrder letting results go (gc), a same-module restart, every
held kind alone, two mounts in one alert, a branch not selected, the
fallback's stderr line and every tool there, 16 reads at a time at grep
and listing, every handle closed, a skipped file's size when it changes
as read, a root replaced mid-read, paths with dot and empty segments on
both kinds, a mixed host's descriptions, the omitted mode, no incomplete
for ignored children, and grep's order with its first file held back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts; main's changes since 76623a3 touch no file this change does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kind refuses '.', '..' and a file's trailing '/'

Takes out the path normalization 42e0533 gave every mount: anima-research#536 already
normalizes every mount's paths in parsePathAsGiven, with rules of its own,
so that rule is anima-research#536's to make (Ada-1017's call). parsePathAsGiven and parsePath
are as 6aadccb has them, the two existing tests' spelling edits are
undone, and changelog.d/workspace-path-one-spelling.changed.md goes.

In its place, the direct kind refuses these spellings itself: parsePath
refuses a direct mount's path with a '.' or '..' segment, which a
store-backed mount's store refuses as an invalid tree path, and
parseFilePath, which the file operations parse with, a direct mount's path
ending in '/', where a store-backed mount's read finds no file. Both read
what parsePathAsGiven gives, so once anima-research#536's parse lands they see no such
path. A directory's path may still end in '/'.

From Esther-2584's read of 6aadccb..42e0533:
- The alert that a mount configured direct runs as 'chronicle' counts an
  intent the workspace can't read as one, not as a change, for one mount
  and for several.
- ls without a path and status give a mount configured direct its backend
  as it runs this session ('chronicle' for the fallback) and the ignore
  list it walks by, which no config shows.
- The fragment says one ops alert names every such mount.

Tests: the normalization test is replaced by one of the direct kind's
refusals; the fallback test checks its ls and status; the held-kinds, ten-
at-a-time and two-mount tests check the alert's counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers an internal review of e566cc6.

The fallback alert:
- Its words of fixed length come first: what's happening and the two ways
  to start a mount direct, then the mounts with what each holds, then the
  held list, so the webhook's kept head (about 346 of 500 characters) holds
  the ways out however many mounts it names and however long their names.
- The materialize route reads as steps: configure the mount 'chronicle'
  and read-write, materialize it, and follow materialize's answer.
- With several mounts it says to settle each change held at a mount to
  start that mount direct.
- An intent the workspace can't read is named with the way that settles
  it, a sync of the path, since materialize's selection leaves such an
  intent with no workspace entry unselected.

The decision:
- chooseKinds decides every mount before it sets any, so a read of the
  store that throws leaves the earlier decisions and fails the start.
- A mount configured direct that ran as 'chronicle' starts store-backed in
  initStore, and runs direct only once a decision finds nothing held.
- Tests in the host's order (start, then initStore) and the store-first
  order, and of a decision that can't read the store.

readBytes: every refusal from the open asks observePath, so a
link to a directory it can't open gets the listing's reason, and a regular
file it can't open keeps the open's.

Tests: the grep order test holds its first file's reads until
fifteen others are read, and asserts it; the inOrder test settles its works
in reverse by hand; each held kind is settled by a sync of its path and the
next start is direct.

Text: a mount that ran direct and runs as
'chronicle' again takes in disk's changes as its listings, searches and
syncs reach them; a direct read that finds its root replaced as it opens
the file is refused; watch 'never''s doc says a read observes only a path
the store doesn't hold; MountState.direct's doc names both decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers an internal review of a25a425, and Esther-2584's read of it.

The fallback alert:
- The materialize route ends by restoring the mount's direct config, after
  "then start it again"; it left the mount configured 'chronicle' and
  read-write before, so its next start ran as configured (found by Esther-2584).
- Where an intent the workspace can't read is held, the ways out say only a
  sync settles one, past the ten paths named too, and its label says only a
  sync of the path settles it: materialize doesn't, and a sync does where it
  can read disk's copy.
- The lead no longer says why it keeps the mount store-backed, which the
  mounts and their counts say after the ways out, so with several mounts
  the fixed head runs 332 characters, inside the webhook's kept start.

The direct kind's refusals say "doesn't take" and "give the
path ... without it", since ls, glob and grep name directories and write,
edit and delete don't read; parsePath's comment gives their reason, the
store-backed kind's own refusals, which an empty segment isn't among.

Text: types.ts says the refusals are the tools' and the
host's reads', and that a generated file is answered, and
resolveAbsolutePath maps a path, as on any mount.

Tests: a decision whose read of a second mount fails leaves
the first as it was; the same module started again on a branch that holds
a draft runs as 'chronicle'; the intent sentence past the ten paths named,
and within the webhook's kept start beside long mount names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ixteen are under way

The test counted the most opens (for grep) and lstats (for the listing)
under way at once, holding each for a turn. Before its file's lstat, each
of the listing's observations resolves the file's parent directory, so the
count depended on how those steps interleaved: CI's macOS Node 20 leg
counted 15 for the listing, and holding every sixteenth realpath for
200 ms gives 15 on Linux too. inOrder starts all sixteen observations
before it awaits the first.

The helper now counts only the files' own calls, and holds them until
sixteen are under way, and 50 ms longer, in which a reader wider than
sixteen would start more. So the count shows the reader's width however
the steps before each call interleave: with that same hold, it counts 16.
A narrower reader is let go after 5 seconds, so it fails rather than
hangs.

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

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no new actionable issue was found, and the previous finding is fixed.

Summary

Adds opt-in, read-only direct mounts whose tools read disk without storing files or starting watchers.

  • Read-only direct mounts show files from disk as they are.
  • Direct mount checks follow the root currently on disk.
  • Stored changes keep a configured direct mount on Chronicle.

Reviews (2) · Last reviewed commit: "test(workspace): the gone-file test find..." · Reviewed by Greptile

Comment thread src/modules/workspace/index.ts Outdated
Beth-2709 and others added 3 commits October 19, 2026 06:44
main brings anima-research#463's edit planner and edit_many, anima-research#350, anima-research#562, anima-research#563 and anima-research#566.
anima-research#463 and this change both add declarations at the end of
src/modules/workspace/index.ts, and both stand. Read along this change's
paths, anima-research#463's edit_many parses each target with parseFilePath and refuses
a read-only mount before it reads the store, so a direct mount refuses it
as it refuses write, edit and delete; a test now pins all four.

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

A direct mount reads beneath whatever directory is at its root at each
call, with no check against an earlier root, but which mounts are nested
in it was placed at start. So a root repointed through a link read
another mount's directory as its own. Now, where a check finds a direct
mount's root elsewhere than the last start placed it, it nests in it
what a start would nest there now (movedNesting): a mount configured
beneath its path, at its configured directory and at where that
directory resolves beneath the root, and any other mount whose root, as
the last start placed it, lies beneath where its root lies now, each
directory also as disk spells it. Each check judges a path by the root
it resolved. A file tool's parse resolves the root once, for its lexical
check and for where the path leads beneath that root. A read, a walk and
grep's check of a path it's given each resolve it for themselves, so a
root repointed during a call can only have a later check refuse what an
earlier one let through. A root that's gone is no move, and its read
says it's unavailable.

Worked out from the configured paths and the start's placement, it
resolves nothing but directories beneath the moved root and changes
nothing shared: no other mount's placement, watcher or status moves, and
a share that doesn't answer holds only its own mount's calls, unless a
configured mount's directory beneath the moved root is a link into that
share, where that mount's files lie. What waits for the next start is a
mount configured elsewhere whose own root has moved too, its spellings
through other links, and the other mounts' own placements. So a direct
root moved into another mount's directory is that mount's too, as a
nested mount's root moved while running stays placed. resolveAbsolutePath,
being synchronous, keeps the start's placement, and its doc says so.

Where the alert names an intent the workspace can't read, it now says to
sync that path, which settles it, in place of saying only a sync does: a
forced materialize settles one where the workspace holds a copy there,
but where it holds none, materialize neither selects nor names it. The
intent's label says a sync of the path settles it or says why not, since
where disk's file can't be read the sync skips it and says so.

The 16-at-a-time test holds the sixteenth call half a second, so a
wider reader's next call can arrive under load; the write tools' test
checks materialize too; types.ts says which wakeOnChange and
autoMaterialize a direct mount refuses, and the changelog that
materialize passes over a direct mount, as any read-only one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Of what main brings since this branch last merged it, only anima-research#576's
tombstone-first deletion changes the workspace module's files. anima-research#576
makes status count a tombstone as a workspace deletion only where its
entry is gone. This change moved that count into heldChanges, which also
decides whether a mount configured direct starts direct, so heldChanges
takes anima-research#576's rule. A tombstone beside an entry that's still there holds
no change disk lacks, so it keeps no mount from starting direct, and
heldChanges counts its entry where disk lacks it, as it counts any
other. A test checks both sides: such a tombstone lets the mount start
direct, and the same tombstone holds it once its entry is gone. Both
sides' changes to the imports stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beth-2709 and others added 7 commits October 19, 2026 16:27
main brings anima-research#536, a mount's include, which is its boundary for content. Where the two changes meet, the include holds on a direct mount as on any other, and a direct mount's moved root takes anima-research#536's new checks with it.

- The include reaches a direct mount through its view and the checks both kinds share. Its walks pass over what the include leaves out, its listings and greps show none of it, and its file tools and the host's reads refuse it, as on a store-backed mount.
- A direct mount has no history. What a store-backed run of a mount configured direct left in the store isn't its own, so grep's choice of file or directory (namesFile) and the include's scope check (refusesAsOutside) read disk alone on a direct mount.
- heldChanges counts only what the include covers, as status counts it. What the store holds outside the include is no change of the mount's, and no push writes it, so it no longer keeps a mount configured direct from starting direct.
- A direct mount whose root has moved nests each mount with that mount's own include, as nestedOf gives it. A link into a nested mount's directory is then judged by that include there too.
- parsePath resolves a direct mount's root once, and returns it with the nesting there. Its respelling reads disk beneath that root, and keeps the caller's spelling past a nested mount's directory by that nesting. The file tools' two checks, the include's scope check and grep's check of a path it's given take that nesting too, and the file tools resolve where a path lands beneath that root. Their other reads of disk still go through the configured path. anima-research#536's respelling and scope check read the last start's nesting, so on their own they'd judge a moved root's paths by a nesting the file tools no longer take.
- The direct mount's own refusals of a "." or ".." segment and of a file's path ending in "/" are gone. anima-research#536's parse reads every mount's paths in one spelling first, so they saw no such path. A test now gives each spelling to both kinds and compares the answers.
- Both sides added shownValue. This keeps anima-research#536's, which also shows a value JSON.stringify throws on, such as a list holding a bigint.

Tests: the include on a direct mount, beside a store-backed one under the same calls; history outside the include, which keeps no mount from starting direct; a scope judged by disk and not by history; a moved root with a nested mount's include; a moved-away root and a path the include leaves out; and, on a disk that folds case, the respelling past a nested mount's directory under a moved root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey resolved, and a moved root nests what a start nests there

A direct mount's parse resolves its root once, and its checks of a path took the nesting there, but some of their reads of disk still went through the configured path. In a call whose root was repointed between the parse and one of them, two roots met in one check. landingRefusal then read a link on the path's way at the new root and named its target by the old root's nesting, so a refusal could name a file another mount's include leaves out. Now each of the parse's checks reads disk beneath the root the parse resolved: landingRefusal's link lookups, namesFile's and refusesAsOutside's directory reads, and linkReadError, which takes the root the check that found the link resolved. A read's own link checks read beneath the root it opens under, as its other checks judge.

A direct mount whose root has moved nested a mount configured elsewhere only where that mount's real root lies beneath the moved root. A start also nests a mount where its configured path lies beneath a directory that is the outer mount's root on disk (readNesting's throughRoot). With a mount configured at b/work, a link to b itself, a root moved to b didn't nest work there, though a start there nests work at work, so its answers for ref/work weren't a start's. movedNesting now nests a mount by its configured path too, beneath each directory the last start found where the moved root lies, from that start's placement (placedAbove), resolving nothing new.

On a disk that folds names, a read still judges the include by the path as its parse spelled it, so a root repointed between the two can have it serve a file the new root spells otherwise, as a rename during a call can on any mount. movedNesting's doc and the description say so.

Texts: the include's doc says a direct mount has no history, so disk alone decides there; the description and changelog say a direct mount's status gives only its path, mode, backend and ignore list; and generatedFileKey's comment names the traversal guard it matches.

Tests: a root moved after the parse is judged against the include beneath the parse's root, and its refusal names nothing another mount's include leaves out; a mount configured through a link to the moved root's new directory is nested as a start there nests it; the moved-away test gives sync and materialize too; and an image the include leaves out is refused by read_image in the include's words. The parse-root race test now compares the raced read with one made at the parse's root, which it now matches: the link on the way is read beneath that root, so the refusal is the link's.

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

movedNesting places another mount two ways, as a start's readNesting does: by its real root as the last start placed it, where that lies beneath the moved root, and by its configured path beneath a directory that start found where the moved root lies (throughRoot). Since the second was added, every moved-root test's mount was placed both ways, so dropping the first changed no answer. This test configures work at w, a link to b/deep/work, so no directory above w is b, and only its real root nests it in ref once ref's link is repointed to b. The moved module's answers to a read, a host read and a grep are compared with a module started fresh with ref at b, which nests work at deep/work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…each way to it, and a direct view reads beneath the root it resolved

A direct mount whose root has moved nested a mount configured beneath its path at its configured directory and where that directory resolves, but not beneath a directory on the way that is the root itself, as a start's readNesting nests it (throughRoot). With work configured at link/sub/work, where sub and work lead to . at the new root, a start there nests work at sub/work and at work, and a moved call nested it at sub/work alone, so its answers for ref/work/file.txt weren't a start's. movedNesting now resolves each directory on the way beneath the moved root, and nests the mount beneath each that resolves to the root, and beneath a directory above the configured path that the last start found where the root now lies.

A mount configured at the moved mount's own path lies where its root does, so a start nests it nowhere in it. movedNesting took it for a mount configured elsewhere and nested it where the last start had placed its root, which could lie beneath the new root. Now it's nested nowhere.

movedNesting left the start's placement in place for any failure to look at the moved root, so an error other than absence had a call judge a moved root by the nesting of the root it left. Now only absence (ENOENT, ENOTDIR) leaves it, as the doc says: the parse's checks then keep the start's placement, and a read says the root is gone. It also takes one start's placements before its first await, so a start in the meantime can't give one call a nesting from two starts.

A direct mount's walks and grep's reads opened their files through the configured path, while judging containment and nesting beneath the root their view resolved. A root repointed between the two had a read open a file its checks never judged, and by our reading that could serve a file a nested mount's include leaves out in that mount's directory at the new root. Their view now reads beneath the root it resolved, so opening and judging use one root.

linkReadError's, namesFile's and landingRefusal's root, and landingRefusal's nesting, are now required, so no caller reads the configured path where it meant the root a parse resolved. Store-backed callers give the configured path and the start's nesting.

Texts: openBeneathRoot's comment says its link checks read beneath the root step 0 checked while its lstat and open go through the configured path, and step 5 refuses a read whose root moved; the include's doc says a mount running direct has no history; and movedNesting's comments say what it takes from the last start and what waits for the next one.

Tests: a mount configured beneath a moved root through a directory that's the root itself; one beneath a root moved to a directory above its configured path; two mounts at one path; a moved root that stat can't look at for a reason other than absence; a grep of a named file whose root is repointed before the open; grep's and a listing's judgements of a path made beneath the parse's root when the root moves after; and a directory a nesting from an unfiltered placement would claim, in the test of a mount configured through a link to the new root.

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

A direct mount whose root has moved nests the other mounts as a start there would. movedNesting wrote that rule out a second time, beside readNesting's, and the two could part on a link layout: a directory above the moved mount's own path that leads to the root since the last start, or a second mount configured at the same path whose parent is the root, gave a moved call answers a start there wouldn't, and by our reading so could the order of two mounts' spellings on a disk that folds case.

placementOf now holds the rule once: a mount is nested at its directory as configured beneath the outer mount's path, beneath each directory above its configured path that is the outer root on disk, and where its real root lies beneath the outer root, each also as disk spells it, with its real root claimed where it doesn't hold the outer root. readNesting places every pair with it from what the start resolves. movedNesting places each mount with it from where the mount's real root and the directories above its configured path lie on disk: resolved now for a mount configured at the moved mount's path or beneath it, beneath the moved root where they lie beneath that path and as resolving the path finds them above it, and as the last start found them for any other mount. So a moved root nests as a start would by construction, wherever it knows what a start would know, and the gap for a mount configured elsewhere whose own root has moved since stays as the doc names it. The skip of a mount at the moved mount's own path, and the filter on the last start's directories beneath it, go: the shared function places such mounts as a start does.

The doc of what a read or a walk serves after a root repointed mid-call now says what holds: no file the include leaves out, nothing a walk serves in another mount's directory as its own nesting places it, and nothing a read serves in the directory of a mount its own nesting claims by real root. It names the two races left to whoever writes the disk, as a rename during a call is on any mount: on a disk that folds names, a read judging the include by the path as its parse spelled it, and a mount nested by its directories alone, judged by the parse's nesting where the root moves between a read's parse and its open.

Texts: MountView.root's comment says it's the directory its readers read beneath, a direct mount's resolved root among them; diskView's doc says which reads open beneath its root and that a read of one file goes through openContainedFile; and landingRefusal's doc says what each caller gives it.

Tests: four that compare a moved root's answers with a fresh start's, for a mount at the moved mount's own path, nested beneath a directory above that path that's the root now; a mount beneath a root whose path's own directory above leads elsewhere now; one beneath a directory on the way that's the root though it's not the first, and beneath none that isn't; and a root moved to a directory beneath its own configured path. A fifth checks that a root which now leads through a file keeps the start's placement, as a gone one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a read leaves, where callers read them

- MountConfig.include names, beside the four cases any nested mount has, the race a direct mount leaves to whoever writes the disk: a root repointed between a read's parse and its open can have the read serve a path in the directory of a mount nested in it by its directories alone.
- movedNesting's doc says it resolves the directories on the mount's own configured path, and those beneath its root, not only the path.
- A mount at the moved mount's own path takes its real root from the same resolution as one beneath that path, since resolving the moved root gives the moved root.
- A test's title says what it pins: a directory on the way that's the root though it's not the first.

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

At 4d182eb, the direct mount's test that a file gone between the walk
that listed it and its read is neither listed nor searched failed on CI's
macOS legs, Node 20 and 24: the listing named f30.txt. The test's listing
half removes the file when the listing looks at it, and it found that look
by comparing the path looked at with one spelled from the test's temp
directory. Since e07eb6b, a direct mount's listing reads beneath the
root it resolved, and on macOS the temp directory lies under /var, a link
to /private/var. So the two spellings differed, the comparison never
matched, and the file stayed. The test now finds the look by the file's
name, as its grep half already finds its file by inode.

With the temp directory reached through a link, on Linux, the direct
mount's test file fails this one test at 4d182eb, and none at
50e8fb7 or with this change, where the two removals this test catches,
a listing that shows a gone file and a grep that skips one, are still
caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit b0b13d3 into anima-research:main Oct 20, 2026
9 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 20, 2026
…how text as a 'chronicle' mount's do

Anarchid's nightly review R3-1 (anima-research#465, at ac6f05d): since anima-research#564 merged,
this branch conflicted with main, and the merge decides whether a direct
mount gets this change's read and grep.

main's direct mounts (anima-research#564) moved where read and grep get a file's bytes:
read takes them from bytesOf, which on a direct mount reads disk as it is
(bytesOnDisk) and on a 'chronicle' mount syncs the path first; grep takes
each text from textsFromStore or textsFromDisk. This branch's read checks
disk inline before reading the workspace's copy, and its grep searches each
copy's bytes through fileText, so the two meet in four places in
src/modules/workspace/index.ts. Each is resolved so a file's text is shown
the same way whichever kind of mount holds it, since read and grep are one
tool for one agent and a direct mount changes only where the bytes come
from:

- bytesOf now gives the bytes and the read's notes. On a 'chronicle' mount
  it runs this branch's disk check in place of ensureSynced: the named pass
  over the path, a change disk made taken in first, a pass that gave up
  read as a copy disk couldn't be checked, and the notes on which copy the
  read shows. On a direct mount it gives bytesOnDisk's bytes with no notes,
  since nothing else is kept there. handleRead sends both through fileText
  and linePage or characterPage, so a direct mount's pages leave out a CRLF
  end's CR and a BOM, say so, cut a long line with its rest named, and
  stop at the call's result limit.
- GrepText carries each text's bytes in place of a decoded string, so a
  direct mount's grep matches fileText's lines too, with each file's
  lineEndings and the notes on a pattern that names a CR or U+FEFF.
- readBinary's doc keeps both sides' facts: a path the workspace doesn't
  hold observed first, a held one read as held, and a direct mount read
  from disk.

Before this resolution, a direct mount on main read a CRLF file's lines
with their CR, gave a 60,000-character line back as 60,083 characters of
JSON with no note, and found nothing for two$ in a CRLF file.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant