Skip to content

fix(workspace): drafts take one spelling on a disk that folds case, and an entry keeps its own - #547

Merged
nissa-seru merged 12 commits into
anima-research:mainfrom
nissa-seru:fix/194-drafts-take-workspace-spelling
Oct 19, 2026
Merged

nissa-seru merged 12 commits into
anima-research:mainfrom
nissa-seru:fix/194-drafts-take-workspace-spelling

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

On a disk that folds case, as macOS's does by default, a path to a file that isn't on disk yet now takes the spelling of the workspace's draft it equals in lower case, so a second draft spelled in another case is the first one. And a path that names a workspace entry exactly keeps that spelling, so an entry that disk lists in another spelling is read, synced and deleted by its own path again, and a file deleted in the workspace while disk keeps its copy is still reached by its own path. The second part mends something #525 broke: since #525, such an entry was out of reach of every tool, delete of its path deleted the other file, and a file deleted in the workspace came back on a read.

Problem

Probed on main c0155d39 on Linux, with Node 24, on ext4 and on the FUSE filesystem described under Tests, which folds case and NFC against NFD. The mount's writes stayed drafts until materialized, as by default (autoMaterialize: false).

  • Two file drafts that differ only in case, src/New.txt ("first") and src/NEW.txt ("second"), were two entries for one file.

    • A materialize wrote NEW.txt and skipped New.txt: "not written: something appeared at this path since it was checked". The next materialize called src/New.txt a conflict.
    • A forced materialize wrote both, the second over the first. The next sync kept the later bytes under NEW.txt and deleted New.txt, so "second" was gone.
  • Since fix(workspace): take a path as disk spells it on a disk that folds names #525, the skipped draft couldn't be reached by its own path. Every tool took the path as disk spells it, so it reached NEW.txt:

    • read work/src/New.txt answered with NEW.txt's bytes;
    • sync and a forced materialize of that path, the remedies the conflict names, acted on NEW.txt and left the draft as it was;
    • delete work/src/New.txt deleted NEW.txt, and materialize with applyDeletions then took it off disk.

    At 0a7fd161, main just before fix(workspace): take a path as disk spells it on a disk that folds names #525, the same calls read, synced and deleted the draft itself.

  • A file deleted in the workspace, with its disk copy kept, was out of reach of its own path too, once disk listed it in another case. Greptile found it on 5da80f9a, an earlier head of this change, which kept an entry's spelling but not a deletion's. Such a file has no entry, only the deletion, so its path took disk's spelling. There, a read of the path took the file in again, and a materialize of the path with applyDeletions missed the deletion. Where disk had changed the file since the deletion, the read took it in rather than answering that it's in conflict. With main b82ba630d's sources, the two tests this change adds for it fail too. In the first, delete of the path answers with disk's spelling, as above. In the second, where the file is deleted before disk changes it, the read takes it in again. Both pass at 0a7fd161, on the folding disk.

  • Two directory drafts, Docs/a.txt and DOCS/b.txt, became one disk directory, DOCS/. The next sync announced deleted work/Docs/a.txt and created work/DOCS/a.txt. Nothing was lost.

  • A file draft and a directory draft of one name, src/Item and src/ITEM/x.txt: the directory was written, and the file draft was skipped and stranded, as above.

On ext4 each pair was two files, as it should be.

Changes

  • diskSpelling (src/modules/workspace/observe.ts) takes what the workspace holds of the mount (WorkspaceEntries), which parsePath passes it, so every tool's path goes through it:
    • a path the workspace holds an entry at exactly, or records an intent at, such as a deletion disk hasn't been given yet or a conflict, keeps its spelling whole, and a last segment where the workspace holds either at exactly that path keeps its spelling once the directories have taken disk's;
    • disk's spelling comes next, as before;
    • a segment disk has nothing for takes the name of an entry it equals in simple case folding, letter by letter (lowered, entryName), where the directory reached folds case;
    • none of this applies where disk's spelling puts the path in a nested mount's directory: there the path keeps disk's spelling.
  • entriesOf (src/modules/workspace/index.ts) gives it the mount's entries on the current branch, the paths it records an intent at there, and which nested mount's directory holds a path (holder), by holderOf, the nested-mount checks' own reading of a path.
  • foldsCase tells whether a directory folds case without writing to it. start reads it for each mount's root where disk has it, at the real root readNesting places, before it sets anything. readNesting now returns those real roots beside the nesting.
  • listedName also returns whether it found the segment's file on disk, through a variant or, for a segment with no variant, as given, and what looking the segment up with its case flipped showed of the directory.

What each part is for

  • An entry's own spelling first. An entry and the file disk lists can be two names for one file: a draft beside a file made on disk in another case before the draft was materialized, a draft whose directory disk comes to list in another case, or two calls in flight at once, each naming a new file in a different case. Taking disk's spelling first sends every path to the file disk lists and leaves the entry out of reach, which is what fix(workspace): take a path as disk spells it on a disk that folds names #525 did. With the entry's own spelling first, a read, delete and the conflict's remedies act on the entry the path names. Whether the whole path names an entry is read before the walk awaits disk, and where it doesn't, the last segment is checked again once the directories have taken disk's spelling. A path the workspace records an intent at counts as an entry here. A file deleted in the workspace while disk keeps its copy has no entry, only that intent. Were the intent not counted, a path to the file would take disk's spelling once disk listed the file in another case, so a read would take the deleted file in again, and a materialize of its path with applyDeletions would miss the deletion. A conflict is settled by a sync of its own path the same way.
  • An entry's name where disk has nothing. On a disk that folds case, a draft's path names the file its entry will be. Taking an entry's spelling makes a second draft in another case the same entry, as disk will once it's written, and lets a read in another case find the draft. A directory segment works the same way, so directory drafts in two spellings are one directory.
  • An entry's spelling takes no path out of a nested mount's directory. A nested mount's directory is the nested mount's, and the checks that refuse a path there compare spellings exactly, knowing the configured one and disk's. An outer entry in another spelling, kept from before the nested mount was configured or before its directory was on disk, could otherwise give a path a spelling they don't know: a stale draft in Proj/ would respell proj/inner/x.txt, which main refuses as a nested mount's at proj/inner, and so would it proj/other/../inner/x.txt. So the step spells the path as disk does, and asks holderOf, the checks' own reading, .. included, whether that lies in a nested mount's directory. Where it does, the path keeps disk's spelling, which the checks refuse as on main. Elsewhere the entries' spelling stands, so a sibling of a nested mount's directory takes a draft's directory, and a path the entries spell as configured is refused as the nested mount's where disk's spelling, in another case, isn't.
  • Simple case folding, letter by letter. Each letter becomes the lower case of its upper case, so Σ and a word's last ς are both σ, ſ is s and µ is μ. A letter whose upper case is more than one letter takes its own lower case instead, as the Greek ᾈ takes ᾀ. So ß, whose upper case is SS, stays ß. A letter that comes back as more than one either way, as İ does, stays as it is, and so does dotless ı, which case folding keeps apart from i. A name's two normalization forms, and letters only a fuller folding joins, such as ß with ss, stay apart too: no listing shows two such entries to be one file, and a disk that folds case needn't join them.
  • A name with nothing to flip, looked up as given. Whether disk has a last segment is learned from its variants, its case flipped and its other normalization form. A name with neither, such as 2024, or ς alone, has no variant to try, so it's looked up as given. Without that, a draft that folds to it would stand for disk's file, and every such path would read the entries under its directory. A name with a letter that case maps, as ς does, can reach a file disk lists in another spelling, such as σ, on a disk that folds more than case pairs, so for it the directory's listing settles whether disk lists it as given.
  • Not folding a name again for each entry beneath it. Entries beneath one name mostly come together in path order, so entryName skips a name equal to the one it has just compared. A name is compared again only where another came between its entries, as N.txt comes between N and N/x.
  • Exact before folded among entries. Where the workspace already holds two entries that fold together, a path naming one of them keeps it.
  • Only where the directory folds case. On a disk that tells case apart, New.txt and NEW.txt are two files and stay two.
  • Reading whether it folds without reading more of disk per call. The root's folding is read once at each start. Beneath it, the walk already looks each directory's name up with its case flipped (fix(workspace): take a path as disk spells it on a disk that folds names #525), which shows whether the directory holding it folds case, and the step takes that for the directory. A directory whose name has no letter to flip takes the nearest directory above that told. So the step reads nothing more of disk for this.
  • foldsCase, by a name the directory lists or by its own name. A name the directory lists, looked up with its case flipped, shows the directory's own folding. A mount's root is often empty when it's new, which is when drafts are made, and then its own name, looked up flipped in the directory holding it, tells instead, where both are on one device, each as a link to it leads. Whether that directory folds is told by its own entries, the root's name and the flipped name, each looked up without following a link there: a link at the flipped name to the root is another file, and a root that is a link is found as itself. A root whose name has no letter to flip takes the folding of the directory holding it, on the same condition. A root on a filesystem of its own isn't judged by the directory it's mounted in, which may fold otherwise, so it reads as telling case apart until a start finds a name in it to tell by. A root that isn't there yet folds as the directory it would be made in. start asks it of each root where disk has it, the real root fix(workspace): a nested mount is its directory wherever disk puts it #535 places, so a root configured through a link is judged by the directory its files are in, not by the one holding the link.
  • Skipping a name whose flipped spelling is listed too. Two hard links that differ only in case are one file on any disk, so they'd make a directory that tells case apart look as if it folds.
  • Only where disk found no variant of the last segment. A file disk lists under the spelling given keeps it, even where an entry has a name that folds to it.

Taken out while building:

  • A lookup of the last segment as given, to learn whether disk had it. It was the first lstat of that name, so it changed what test/workspace-three-way.test.ts's race test observed. Whether listedName found a variant answers the same question, for a segment that has one.
  • Reading foldsCase for a directory on each call. That listed the directory for every new path, on every disk. The walk's flipped lookups and the root's folding read at start cover it.
  • A stop at .. among the entries. No entry is named .., so the entries' walk stops there anyway.
  • Leaving entries in a nested mount's directory out of every lookup. It dropped the entry spelled as configured that got a path in another case into a nested directory not on disk refused, it changed the spelling of paths outside nested directories, and it folded every entry on each lookup.
  • Asking, of each name an entry would give, whether it lay where the nested-mount checks bite (usable). It was a second account of where they bite, beside theirs: compared loosely, and asked of the path before the walk had respelled the rest or resolved a .., it let a stale entry's spelling take proj/other/../inner/x.txt out of a nested mount's directory, gave one directory two spellings in two tools, and withheld spellings no check needed withheld. Asking holderOf of both finished spellings is the checks' own answer.
  • Keeping an entry's whole path before walking disk. Disk's spelling is needed to see whether the path lies in a nested mount's directory, so the walk runs for every path, as it does on main. Whether the path names an entry is still read before the walk, and the entries' walk doesn't run for one that does.
  • Comparing where disk's spelling and the entries' each put the path. Where disk's spelling lies in a nested mount's directory, the path is that mount's either way, and disk's spelling gives the refusal main gives.

What callers will notice

  • On a disk that folds case, a path that disk has nothing for, spelled in another case than a workspace entry, acts on that entry and is named by the entry's spelling, unless disk lists one of the entry's directories in another spelling (see Out of scope): write work/src/NEW.txt answers with path: "work/src/New.txt" where the draft src/New.txt exists. This holds for read, write, edit, delete, sync, materialize, ls, glob and grep, and for readBinary, readBinaryFromDisk, readFileFromDisk and writeBinary.
  • On any disk, a path that names a workspace entry exactly acts on that entry, and one that names a file deleted in the workspace, or a conflict, acts on that, even where disk lists the file, or its directory, in another spelling, unless disk's spelling puts the path in a nested mount's directory: there it's refused as that mount's, as on main.
  • On a disk that tells case apart, every answer is as before, except that where such a disk folds normalization forms, a path that names a workspace entry exactly, or a deletion or conflict the workspace records there, or does once its directories take disk's form, keeps its form, where it took the form disk lists, outside nested mounts' directories.
  • Cost. On a disk that folds case, a path that's neither on disk nor an entry reads the workspace's entries beneath the deepest of its directories disk has, about 1.1 µs per entry, and folds the name of each entry directly under that directory once. So a new file in a new top-level directory reads every entry of the mount, as a new file at the root does. A path that names an entry reads nothing of the entries but that one.
    • Measured for a new file at a mount's root, on a shared machine. Where the mount's entries lie in 100 directories: 1.2 ms with 1,000 entries, 12 ms with 10,000, and 66 ms with 50,000. Where they're all files in the root itself, so every name is folded: 1.9 ms, 21 ms and 118 ms. That was at load 2–3, at e5183995, whose step for such a path this head runs unchanged but for one more store lookup in each of its two checks of whether the path names an entry. At this branch's merge of main, 43df5134, the machine stayed at load 20–34, and three runs gave 78 to 88 ms and 173 to 201 ms at 50,000.
    • Creating many new files under one large directory, or looking up many paths there that don't exist, pays that once per path, so a script on macOS that does either thousands of times will notice.
    • On any disk, each start lists each mount's root once more, and a path whose last segment has no variant to try, no letter to flip and one normalization form, such as 2024, looks that segment up as given once more than on main, and one with a letter that case maps, such as ς, found that way, lists its directory once. On a disk that tells case apart, that's all that's read of disk beyond main, and the workspace's entries, and the intents it records, are read only to see whether the path names one.

Tests

test/workspace-path-spelling.test.ts gains these tests.

On a disk that folds case:

  • a second draft in another case is the first one: one file is written, and a sync finds nothing to change;
  • two directory drafts that differ only in case are one directory, and a sync moves no entry;
  • a draft beside a file made on disk in another case is read and deleted by its own path, the draft is gone afterwards, and disk's file is left as it was;
  • a draft whose directory disk comes to list in another case is read, written and deleted by its own path, as one entry;
  • a file taken in from disk and then deleted in the workspace, its disk copy kept, which disk comes to list in another case: a read of its own path answers that it was deleted and takes nothing in, and a materialize of that path with applyDeletions deletes disk's copy;
  • the same file, which disk changes after the deletion: a read of its own path answers that it's in conflict, and a sync of that path settles the conflict and the deletion, leaving one entry for the file;
  • the step's rules one at a time: the whole path and the last segment kept, disk's spelling next, the first entry in path order, exact before folded, Σ, ς, ſ, µ, ᾈ, İ and ı, the root's folding against a directory's, a directory whose name has no letter to flip taking the nearest folding, a name with nothing to flip keeping its spelling where disk lists it and taking a draft's where disk has nothing, the same name where disk lists it in a spelling only a fuller folding joins, such as σ for ς, taking the entry's, and a path at an entry reading no other entry;
  • foldsCase for a directory with no letter in its name, an empty one, an empty one with no letter in its name, and a root that is a link to an empty directory;
  • a mount whose root is empty, or not there yet, or empty with no letter in its name, at start takes a draft's spelling at its top level;
  • an outer entry takes no path out of a nested mount's directory that holds it as disk spells it: a file named as a nested directory in another case, an entry beneath one in the other normalization form, and a stale draft in another spelling of the directory that holds an absent nested one, with a path into it spelled as configured, straight or through a ... An entry spelled as configured beneath an absent nested directory gets a path in another case refused as that mount's, and beside an absent nested directory, a sibling and the directory holding it take the stale draft's spelling. Where disk's spelling and an entry's both lie in one nested mount's directory, the refusal names disk's.

On a disk that tells case apart:

  • two drafts in two cases stay two files, and so do two directories;
  • two hard links that differ only in case don't make a directory look as if it folds;
  • nor does an empty directory with no letter in its name, a link at a directory's flipped name, or a root that is a link to its flipped twin.

On any disk: a stop made while start lists a mount's root for its folding leaves the workspace stopped, and a restart takes no call while it lists it.

As in #525, the folding tests ran on Linux on a FUSE filesystem that folds case and NFC against NFD, fully or letter for letter, mounted in a user namespace. They skip where the temp directory tells case apart, which on CI is the Linux legs.

Runs at 3651c815, on this branch's merge of main b82ba630d: tsc --noEmit is clean. This file passes 37 tests with 4 skipped on the folding disk folding fully, 33 with 8 skipped folding letter for letter, and 12 with 27 skipped on ext4. The workspace's test files pass 620 of 647 on ext4, the 27 skipped being this file's folding tests.

The 24 other test files that use the workspace module give the same results on the folding disk at 0b2c093b as with main's code, each test passing or failing alike, but for workspace-watcher's test that a root made again takes another inode number, whose outcome on the FUSE filesystem varies from run to run on both sides. In that run it failed with main's code and passed with this change; at the merge of main, run five more times on each side, it failed in 4 of 5 with this change and in 3 of 5 with main's code. Their only other failures, on both sides, are 10 tests that make a FIFO, a pipe or a socket, which the FUSE filesystem can't. At an earlier head of this change, one run of state-registration-errors under --test-force-exit, as CI runs it, reported only its first 3 of 5 suites, with nothing failing and exit 0. Run again five times on each disk, there and with main's code, it reported and passed all 25 tests each time, as it did at 0b2c093b.

The comparison, red-first and the removals ran at 0b2c093b, which differs from this head only in a changelog fragment.

Red-first, with main b82ba630d's sources and stand-ins for what the tests import: on the folding disk, the ten new folding tests fail, and so do the stop test and the restart test, which fail on ext4 too, since main lists no root at start. On ext4 the tests for a disk that tells case apart pass, as controls.

Each new piece, taken out on its own, fails a test, except those Not verified names: the four in foldsCase, the skip of a repeated name, and reading a root's folding at its real root:

Taken out Fails
an entry's whole path kept the draft whose directory disk lists in another case; the step's rules
the last segment kept once the directories take disk's spelling the step's rules
not running the entries' walk for a path that names an entry the step's rules
not looking the last segment up among the entries for a path that names an entry the step's rules
an entry's name for the last segment the second draft; an empty or missing root; the nested mounts' entries; the step's rules
an entry's name for a directory segment the directory drafts; the nested mounts' entries; the step's rules
disk's spelling kept where it lies in a nested mount's directory the nested mounts' entries
entriesOf asking holderOf which nested mount holds a path the nested mounts' entries
only where disk found no variant the step's rules
a name with nothing to flip looked up as given the step's rules
the listing for a name with no variant and a letter that case maps the step's rules
only where the directory folds the step's rules; on ext4, the two drafts staying two
the nearest directory that told the step's rules
the walk's flipped lookup, taken for the directory the step's rules
the flipped lookup's result the second draft; the step's rules
no result for a name with no letter to flip the step's rules
exact before folded among entries the step's rules
the comparison in simple case folding the second draft; the directory drafts; an empty or missing root; the nested mounts' entries; the step's rules
each letter as the lower case of its upper case, not its lower case the step's rules
a letter whose upper case is more than one letter taking its own lower case the step's rules
keeping a letter that comes back as more than one the step's rules
keeping dotless ı the step's rules
passing over an empty or . segment among entries the step's rules
skipping a name whose flipped spelling is listed on ext4, the hard links
foldsCase by the directory's own name foldsCase; an empty or missing root
foldsCase taking the directory holding one with no letter in its name on ext4, the directories that tell case apart
foldsCase looking the flipped name up without following a link foldsCase; on ext4, the directories that tell case apart
foldsCase comparing the root's own entry, not where it leads foldsCase; on ext4, the directories that tell case apart
foldsCase of a directory not there yet an empty or missing root
start reading the root's folding the directory drafts; an empty or missing root; the nested mounts' entries
start checking for a stop after reading it the stop while start reads a root's folding
start reading the root's folding before it takes calls the restart while start reads a root's folding
entriesOf passing the root's folding the directory drafts; an empty or missing root; the nested mounts' entries
parsePath passing the entries the second draft; both drafts reached by their own path; the draft whose directory disk lists in another case; the deleted file reached by its own path; the deleted file in conflict; the directory drafts; an empty or missing root; the nested mounts' entries
has counting a path an intent is recorded at the deleted file reached by its own path; the deleted file in conflict

Not verified

  • APFS itself: nothing ran on macOS. CI's macOS legs run the folding tests on the runner's disk.
  • Windows: CI has no Windows legs.
  • An agent end to end.
  • The device check in foldsCase: no test reaches it. It decides where a root lies on another device than the directory holding its name: a filesystem mounted at the root, or a root that links to another filesystem. On the FUSE filesystem the tests ran on, a lookup in another case at a mount point reaches the directory the mount covers, so a mounted root's two spellings already reach two files (a run of Ezra-2423's).
  • Following links to the directory and its holder for the device check: it decides only for a link to another filesystem, which no test here builds.
  • foldsCase stopping at the filesystem's root: it decides only where that root can't be listed.
  • foldsCase by a name the directory lists: a name the directory lists tells whether it folds, and its own name whether the directory holding it does. They differ where a directory folds otherwise than its holder, as a root on a filesystem of its own or an ext4 directory with its own casefold attribute can, and no test here builds either.
  • Reading whether a path names an entry before the walk's awaits: it decides only where the branch switches during them.
  • A path whose intent record can't be read counts as an entry for its spelling, as a readable one does: no test builds a damaged record.
  • Reading a root's folding at its real root rather than as configured: the two differ only for a root with no name in it to tell by at start, configured through a link that lies on another filesystem than the root's directory, or in a directory that folds otherwise than the one holding it, as ext4's per-directory casefold allows. No test here builds either. A run of Ezra-2423's that linked from ext4 into the folding FUSE filesystem found the configured read telling case apart and the real read folding, for an empty root and for one not made yet, and the two the same where the root held a file or the link ran the other way.
  • Not folding a name again for each entry beneath it: it changes no answer, so no test fails without it. The cost figures above are with it. For the mount laid out in directories, at the head and load of the cost figures above, listing every entry and folding the first segment of each took 16 ms at 10,000 entries and 99 ms at 50,000, against 12 ms and 73 ms for the listing alone.

Out of scope

By our reading of the code at this head:

  • A file made on disk in another case before a draft is pushed, two calls in flight at once each naming a new file in a different case, and a branch switch or undo between a path's spelling and its commit can still make two entries for one file. So can a directory disk comes to list in another case than a draft's directory before the draft is pushed: a path through disk's spelling of that directory isn't spelled from the draft's entries, which the step reads under the directory as disk spells it. The exact-entry rule keeps each reachable by its own path, and a materialize won't write one over the other unless forced.
  • Drafts that differ only in Unicode normalization form, or in letters only a fuller folding joins, such as ß and ss, stay two entries, and on a disk that folds them, as macOS's folds normalization, a materialize writes one and skips the other. On a disk that folds case but not normalization, a file disk lists in the other form keeps a path from an entry's spelling.
  • Before a mount's first start, a draft at the mount's top level isn't spelled from the workspace's entries, since the root's folding is read at start, and neither is one beneath directories whose names have no letter to flip, which take the root's. Beneath a directory disk has whose name has a letter to flip, the walk's lookup of that name tells, so a draft there is.
  • A directory beneath the root is taken to fold as the directory holding it does, as its name looked up there in another case shows. So a directory set to fold case on its own (ext4's casefold attribute) inside one that doesn't is taken to tell case apart, and one that tells case apart inside one that folds is taken to fold, where a file it holds that isn't yet a workspace entry, named as disk spells it, takes the spelling of a draft that folds to it. A filesystem mounted inside a mount's directory is judged by the directory holding it where that lookup crosses into it. Where the lookup reaches the directory the mount covers instead, as on the FUSE filesystem the tests ran on, the mount point's two spellings reach two files. Then the mounted filesystem's top directory is taken to tell case apart however it folds, and so is a mount's root where the name foldsCase looks up is a mount point.
  • A mount's root that is empty at start reads as telling case apart until the next start where it lies on a filesystem of its own, or is set to fold case on its own inside a directory that doesn't.
  • A path into a nested mount's directory that isn't on disk, spelled in another case than configured, is the outer mount's, as on main, since the nested-mount checks compare spellings exactly, unless the entry whose spelling it takes is spelled as configured there.
  • A mount's root that is itself a mount point may read as telling case apart while it's empty: on the FUSE filesystem the tests ran on, its name looked up in another case reaches the directory the mount covers.
  • Entries already held twice from before this change keep both spellings.
  • A path in a third spelling of a file deleted in the workspace, neither the deletion's nor disk's, takes disk's spelling, so a read of it takes the file in again, as before fix(workspace): take a path as disk spells it on a disk that folds names #525.
  • A file taken in under a spelling disk doesn't list, as a sync of a deletion's path takes disk's copy, is named as disk lists it by the next pass over its directory, which announces it deleted and created. A run on main showed the same for a file that disk comes to list in another case.
  • A file deleted in the workspace, with its disk copy kept, is taken in again under disk's spelling by the next pass over its directory once disk lists it in another case, and its deletion is dropped. Ezra-2423's probe found it at this change's heads, and runs showed the same at main and at 0a7fd161, before fix(workspace): take a path as disk spells it on a disk that folds names #525.
  • resolveAbsolutePath, which is synchronous and reads no disk, answers a path as given, as on main, so a host that writes to its answer on a disk that folds case can make a second spelling of a file the workspace holds.

Related


  • Changelog fragment added: changelog.d/workspace-draft-spelling.fixed.md

Written by Tabitha-2696 (Claude Opus 5.5), contributing through nissa-seru. Reviewed in the house by eight internal reviews, of the change and of each round since, the last including the merge of main, whose findings 5da80f9a answers. Greptile's review of 5da80f9a found that a file deleted in the workspace was out of reach of its own path once disk listed it in another case, which the last commit fixes. Found while probing what #525 left of case-variant drafts, and Ada-1017 settled the design. Ezra-2423 read each round and probed each, and found that a draft whose directory disk lists in another case was out of reach.

Description checked and posted by Fiona-2757 (Claude Opus 5.5).

🤖 Written with Claude Opus 5.5 through the Rookery's tooling.

Tabitha-2696 and others added 11 commits October 18, 2026 13:00
…nd an entry keeps its own (F194)

On a disk that folds case, two drafts that differ only in case were two
entries for one file. A materialize wrote one and skipped the other; a
forced one wrote the second over the first, and the next sync kept the
later bytes under disk's spelling. Two directory drafts became one disk
directory whose files the next pass moved under its spelling. And since
paths take disk's spelling, an entry that disk lists in another
spelling, such as the skipped draft, couldn't be reached by its own
path: read, sync and materialize of it acted on the file disk lists,
and delete deleted that file, which applyDeletions then took off disk.

diskSpelling now takes what the workspace holds of the mount, from
parsePath, so every tool's path goes through it:
- the last segment keeps its spelling where the workspace holds an
  entry at exactly that path;
- disk's spelling comes next, as before;
- a segment disk has nothing for takes the name of an entry it equals
  in lower case (entryName), where the directory reached folds case.
  Names aren't normalized, since no listing shows two entries to be one
  file and a disk may keep normalization forms apart.

Whether a directory folds case is told without writing (foldsCase): by
a name it lists, looked up with its case flipped, skipping a name whose
flipped spelling it lists too (hard links), or, where it lists none, by
its own name looked up flipped in the directory holding it. start reads
it for each mount's root, beside the nested mounts' spellings. Beneath
the root, the walk's own flipped lookup of each directory's name shows
whether the directory holding it folds, which is taken for the
directory; a name with no letter to flip takes the nearest directory
above that told. So the step reads nothing more of disk per call for
this, and on a disk that tells case apart nothing more at all.

The cost: a path that's neither on disk nor an entry, on a disk that
folds case, scans the workspace's entries under its directory, about
1.1 microseconds per entry on a shared machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tries out, and fold letter by letter (F194)

Answers the first review of this change and Ezra-2423's read.

- A path the workspace holds an entry at exactly keeps its spelling
  whole, checked before the walk. Only the last segment kept it before,
  after the directories took disk's spelling, so a draft whose directory
  disk came to list in another case was out of reach of its own path,
  and a second write made a second entry (Ezra-2423's probe).
- An entry in a nested mount's directory, in any spelling equal to it in
  lower case, isn't the outer mount's to spell a path by (entriesOf).
  Kept as given, such an entry's spelling slipped past the nested-mount
  checks, which compare spellings as they are; and a draft directory's
  spelling could take a path into an absent nested mount's directory
  past them too.
- Names are compared in lower case letter by letter, each letter on its
  own (lowered): a word's last Σ is σ as anywhere else, ς is taken as σ,
  and İ, whose lower case is two letters, stays as it is.
- foldsCase judges a directory that isn't there yet, such as a new
  mount's root, as the one it would be made in, and takes the directory
  holding it as telling only where both are on one device, so an empty
  root on a filesystem of its own isn't judged by the one it's mounted
  in. A name whose flipped spelling is listed too, letterless names
  included, tells nothing.
- Tests: the draft beside a directory disk lists in another case; an
  empty and a missing root at start; nested mounts' entries in another
  case; a stop while start reads a root's folding; the store's path
  order in the step's tests; the deleted draft gone from the workspace;
  Σ, ς and İ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecks know it; fold letters simply; letterless and linked roots (F194)

Answers the second review of this change, of b485e97..b62a9b0.

- Entries are no longer filtered from has and under. Instead an entry's
  spelling may stand for a path, the whole of it or its first segments,
  only where it doesn't lie in a nested mount's directory in a spelling
  other than the configured one or disk's (entriesOf's usable), compared
  as loosely as the walk respells a name (foldedLoosely). diskSpelling
  asks it before keeping a path whole, before keeping a last segment, and
  for each name entryName would take.
  - An entry spelled as configured gives a path into a nested mount's
    directory that isn't on disk that spelling again, so the path is
    refused as that mount's, as the first build did.
  - An entry in the other normalization form no longer keeps a path whole
    past the walk, which respells it as disk lists it, so it is refused.
  - A path outside every nested mount's directory takes an entry's
    spelling as before, and the step no longer folds every entry.
- lowered is simple case folding, letter by letter: each letter as the
  lower case of its upper case, so ſ is s and µ is μ as well as ς σ. A
  letter that comes back as more than one stays as it is, and so does
  dotless ı.
- entryName compares each name once, since entries under one name come
  together in path order.
- foldsCase: an empty root with no letter to flip in its name takes the
  directory holding it, where both are on one device; the own-name lookup
  follows links, so a root that is a link, or is reached through one, is
  compared by the device it lies on; and it stops at the filesystem's root.
- Tests: an entry spelled as configured and one in another case beneath an
  absent nested mount's directory, and one in the other normalization
  form beneath a present one; ſ, µ and ı; an empty root named with digits.
- Changelog: simple case folding; a draft whose directory disk comes to
  list in another spelling is found only by its own path; and on a disk
  that tells case apart but folds normalization forms, a path naming an
  entry exactly keeps the entry's form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rectory, Greek titlecase letters fold, and no link at a root's flipped name (F194)

Answers the third review of this change, of b62a9b0..4f4224f.

- usable now refuses an entry's spelling on the way to a nested mount's
  directory, not only at it or inside it, where it spells the segments it
  shares with that directory otherwise than both the configured spelling
  and disk's. An outer draft in Proj/ no longer respells proj/inner/x.txt,
  which a nested mount at proj/inner owns, into a path the nested-mount
  checks don't match.
- entryName asks usable of a name that equals the segment too, so a stale
  entry spelled the same as the path no longer keeps a usable entry in the
  configured spelling from getting the path refused.
- lowered takes a letter's own lower case where its upper case is more
  than one letter, so the Greek titlecase letters, such as U+1F88, fold to
  their small forms again, as simple case folding has them.
- foldsCase looks a root's flipped name up without following a link there,
  so a link at that name to the root doesn't make a disk that tells case
  apart look as if it folds.
- Docs: entryName no longer says each name is compared once (a name such
  as N.txt sorts between N and N/x, so N can be compared again); foldsCase
  says the device rule covers a name with no letter to flip; entriesOf
  says what usable compares and why loosely; listedName says caseFolds
  tells nothing for a segment not looked up as given; the test file's
  header says simple case folding; the changelog says an entry spelled
  otherwise than a nested mount's configuration and disk, on the way to
  that mount's directory or inside it, gives no path its spelling.
- Tests: Later/y.txt refused beside the configured-spelling entry; a
  nested mount two levels down beside an outer draft in another spelling
  of its parent; U+1F88 against U+1F80; on a disk that tells case apart,
  an empty directory named with digits, and one with a link at its
  flipped name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ke, and a root is found as its own entry (F194)

Answers the fourth review of this change, of 4f4224f..11d5561, and
Ezra-2423's read of it.

- usable is asked of the whole path an entry's name would make: the
  segments taken so far, the name, and the rest as given up to a `..`.
  Asked of the name's prefix alone, it refused an entry's spelling of a
  nested mount's parent for every path through it, so a sibling such as
  proj/z.txt beside a nested proj/inner kept the spelling given and made a
  second entry beside the draft Proj/z.txt. A path into the nested
  directory is still refused its unknown spellings, and the directory
  itself still takes only the configured or disk's spelling.
- foldsCase tells whether the holder folds by its own entries: lstat of
  the root's name against lstat of the flipped name, with the device check
  still through links. A root that is a link is found as itself, so an
  empty one on a folding disk folds again (Ezra-2423's probe), and one that
  links to its flipped twin on a disk that tells case apart doesn't fold.
- Docs: entryName, lowered (ß takes its own lower case, only İ comes back
  as two letters), listedName's caseFolds for a segment not looked up as
  given, diskSpelling, usable and entriesOf for the whole-path rule and
  the loose comparison's direction, parsePath and the test file's header
  for the exact-entry exception; anima-research#525's changelog fragment names it too,
  and this change's fragment states the nested-mount rule for whole paths.
- Tests: an empty link root on the folding disk; on a disk that tells
  case apart, a root that is a link to its flipped twin; beside a nested
  mount two levels down, a sibling taking the draft's directory, the
  directory itself taking the configured spelling, and Proj/inner/x.txt
  refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt's directory, by the nested-mount checks' own reading (F194)

Answers the fifth review of this change, of 11d5561..b074813.

Rounds 2 to 4 asked, of each name an entry would give, whether it lay
where the nested-mount checks bite (usable): a second account of where
they bite, beside theirs, compared loosely and asked of the path before
the walk had respelled the rest or resolved a `..`. It let a stale draft
in Proj/ take proj/other/../inner/x.txt, which main refuses as the nested
mount deep's at proj/inner, out of deep's directory; it gave one
directory two spellings in two tools (a sibling took Proj/, ls took
proj/); and it withheld spellings no check needed withheld.

The step now spells the path both ways, as disk spells it and as the
entries do, and asks holderOf, the checks' own reading, `..` included,
of each (WorkspaceEntries.holder). Where disk's spelling puts the path in
a nested mount's directory and the entries' spelling doesn't put it in
the same one, the path keeps disk's spelling, which the checks refuse as
on main. Elsewhere the entries' spelling stands, so a path the entries
spell as configured can be refused where disk's spelling, in another
case, isn't.

So disk is walked for every path, as on main, including one that names
an entry exactly: disk's spelling is needed to see whether the entry's
takes the path out of a nested mount's directory. usable, its loose
comparison and the rest it was asked of are gone, and entryName no
longer filters names.

- Docs: diskSpelling, entryName, WorkspaceEntries.holder, entriesOf,
  parsePath, the test header, and both changelog fragments.
- Tests: the nested-entries test now holds only the entries each
  assertion needs: Inner beside the nested inner, an NFD entry beneath
  cafe, a stale Proj/ beside the absent deep at proj/inner, with
  proj/inner/x.txt and proj/other/../inner/x.txt refused as deep's, and
  later/z.txt beneath the absent later, with LATER/y.txt refused as
  later's; beside deep, PROJ/z.txt and ls PROJ take Proj.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ount's directory, and an exact entry is read before the walk (F194)

Answers the sixth review of this change, of b074813..8381002.

- The holder rule is one question now: where holderOf puts disk's
  spelling of the path in a nested mount's directory, the path keeps
  disk's spelling; elsewhere the entries' spelling stands. Round 5 also
  asked where the entries' spelling lay, and kept it where both lay in
  the same nested mount; the path was refused as that mount's either way,
  and disk's spelling gives the refusal main gives, so the comparison
  paid nothing, and no test could tell it apart.
- Whether the whole path names an entry is read before the walk awaits
  disk, as before round 5, so a branch switch during the walk doesn't
  decide it, and the entries' walk doesn't run for such a path: an exact
  entry whose directory disk lacks no longer lists the entries beneath it
  for an answer it doesn't use. Disk is still walked, for its spelling.
- Docs: diskSpelling, entriesOf (the checks refuse at least what they
  refuse without the entries) and parsePath; the test header; the nested
  test's comment says which paths disk spells into a nested directory and
  why.
- Changelog: the draft fragment states the rule for paths; anima-research#525's says an
  exact entry keeps its spelling unless disk's puts the path in a nested
  mount's directory, that a segment disk lacks keeps the spelling given
  unless an entry spells it, and that a nested mount's directory is known
  in both spellings where it's on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d a path at an entry reads no other entry (F194)

Answers the seventh review of this change, of 8381002..e518399.

- listedName learns whether disk has a last segment from its variants,
  its case flipped and its other normalization form. A name with
  neither, such as 2024, or a lone sigma, had none to try, so found was
  false even where disk listed it: a draft that folds to it stood for
  disk's file, and every such path listed the entries under its
  directory. Such a name is now looked up as given.
- For a path that names an entry exactly, the last segment's entry
  lookup on disk's spelling no longer runs; its answer was never used.
  So a path at an entry reads that one entry and nothing else of the
  entries.
- Tests: a lone sigma disk lists keeps disk's spelling beside a draft
  Sigma; a path at an entry lists no entries and looks up only itself,
  where disk lacks its directory and where disk spells it otherwise;
  where disk's spelling and an entry's both lie in one nested mount's
  directory, the refusal names disk's; the NFD case of the nested test
  is a subtest that says when it's skipped.
- Docs and changelog: a path that names an entry once its directories
  take disk's spelling keeps the entry's spelling too (parsePath, the
  test header, both fragments); the entries' scan reads beneath the
  deepest of a path's directories disk has; an exact entry keeps its
  normalization form outside nested mounts' directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… normalization form takes the nearest folding (F194)

6119671 sent a segment with no variant at all, such as 2024, through
listedName's new early return, which answers no case folding (null) for
it. The test's only name with no letter to flip was 2024, so the rule
that a segment with no letter to flip but another normalization form
reports null rather than "tells case apart" was no longer reached:
taking that rule out failed no test at 6119671.

The step's test now also makes a directory named with a kana that has an
NFD form and no case, and asserts that a draft beneath it takes the
root's folding either way, as 2024 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…research#535 places it

main brought, among others, anima-research#533 (the mount root's identity and bounds),
anima-research#535 (mount roots placed where disk has them: readNesting replaces
spellNestedDirs, and a nested mount's directory has a place per way it
lies there, each with its spellings), anima-research#262, anima-research#537, anima-research#543 and the 0.22.3
release. F194's changelog fragment and its edit of anima-research#525's are both still
unreleased.

The five conflicts were all in src/modules/workspace/index.ts:
- the ./observe.js import: main's names, with F194's foldsCase and
  WorkspaceEntries;
- the nested mounts' field: main's NestedMountEntry[], with F194's
  rootFoldsCase beside it;
- three in start, around what it reads before it sets anything: main's
  readNesting, and F194's root folding read after it, both assigned after
  the check for a stop begun meanwhile.

As Ada-1017 set for this merge, start reads each
root's folding from the root anima-research#535 places, where disk has it: readNesting
now returns its real roots (reals) beside the nesting, and start calls
foldsCase on each. Before, F194 read it at the root as configured, so a
root configured through a link was judged by the directory holding the
link rather than by the one holding the directory the files are in.

F194's step asks holderOf, which now reads every place and spelling
anima-research#535 gives a nested mount's directory, so the step needs no change for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… settled by its directory's listing, and a restart takes no call while it reads a root's folding (F194)

Answers the eighth review of this change, of e518399..43df513.

- listedName looks a last segment with no variant to try, such as 2024
  or a lone sigma, up as given, and took a file found that way as disk
  having it. On a disk that folds more than case pairs, a final sigma
  reaches a file disk lists with a medial one, so a path spelled with
  the final sigma kept that spelling where an entry spells the medial
  one, where the commits before 6119671 took the entry's. Where such a
  segment has a letter that case maps, the directory's listing now says
  whether disk lists it as given; a caseless name keeps the one lookup.
  The segment is still returned as given, so disk's spelling of the
  path is main's.
- Tests: a lone final sigma that disk doesn't have takes a draft's
  spelling; where the disk folds the two sigmas, one that disk lists
  with the medial sigma takes the entry's; and a restart takes no call
  while it lists a mount's root for its folding.
- Docs: listedName's doc says when each segment is looked up and what
  found means for each; the draft fragment's normalization exception
  covers a path that names an entry once its directories take disk's
  form.

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: 4/5 Tier: apex

[Medium impact] The change affects path lookup across the workspace feature.

Fix the pending-deletion spelling gap before merging so deleted tracked files cannot return on read.

Findings

  1. P1 Deleted files come back ▶
Fix with agent prompt
### Issue 1
src/modules/workspace/index.ts:2164
`has` checks only live entries, so deleting an entry also stops later calls from keeping its spelling. On a case-folding disk, sync `src/Name.txt`, rename it on disk to `src/NAME.txt`, then delete `work/src/Name.txt` with `autoMaterialize: false`. The delete correctly records a pending deletion at `src/Name.txt`. But the next call using that same path becomes `src/NAME.txt`, because the live entry is gone.

A path-specific `materialize` with `applyDeletions: true` therefore selects nothing and leaves the file on disk. A `read` can instead import it under the other spelling. Keep exact pending-deletion paths reachable too, and add a test covering a previously synced file.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR makes case-equivalent drafts share a spelling and keeps existing entries reachable by their own paths.

  • Case-folding disks treat case-only draft names as one workspace path.
  • Workspace paths keep the spelling of the entry they name.

Limits explicitly acknowledged by nissa-seru:

  • Concurrent calls, disk changes before a push, and branch/undo changes can still create duplicate entries; exact paths keep them reachable.
  • Normalization-only and fuller Unicode matches stay separate because this change deliberately uses simple case folding.
  • Before the first start, root-level drafts lack the cached folding result. Directories inherit nearby folding behavior; differing per-directory rules and empty filesystem roots remain limited.
  • An absent nested directory in another case remains the outer mount's unless an entry supplies its configured spelling; ownership checks compare exact spellings.
  • Existing duplicate entries stay separate. resolveAbsolutePath remains synchronous and does not read disk.
  • New-path lookups scan entries beneath a directory, and each start adds root listings; these costs are intentional.

Review checks were by code inspection; no tests were run.

Reviews (1) · Last reviewed commit: "fix(workspace): a name with no variant a..." · Reviewed by Greptile

Comment thread src/modules/workspace/index.ts Outdated
private entriesOf(mount: MountState): WorkspaceEntries {
const store = this.getStore();
return {
has: (path) => store.treeGet(mount.treeStateId, path) !== null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Deleted files come back

has checks only live entries, so deleting an entry also stops later calls from keeping its spelling. On a case-folding disk, sync src/Name.txt, rename it on disk to src/NAME.txt, then delete work/src/Name.txt with autoMaterialize: false. The delete correctly records a pending deletion at src/Name.txt. But the next call using that same path becomes src/NAME.txt, because the live entry is gone.

A path-specific materialize with applyDeletions: true therefore selects nothing and leaves the file on disk. A read can instead import it under the other spelling. Keep exact pending-deletion paths reachable too, and add a test covering a previously synced file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 2164

Comment:
**Deleted files come back**

`has` checks only live entries, so deleting an entry also stops later calls from keeping its spelling. On a case-folding disk, sync `src/Name.txt`, rename it on disk to `src/NAME.txt`, then delete `work/src/Name.txt` with `autoMaterialize: false`. The delete correctly records a pending deletion at `src/Name.txt`. But the next call using that same path becomes `src/NAME.txt`, because the live entry is gone.

A path-specific `materialize` with `applyDeletions: true` therefore selects nothing and leaves the file on disk. A `read` can instead import it under the other spelling. Keep exact pending-deletion paths reachable too, and add a test covering a previously synced file.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.

Addressed in 3651c81. has now also counts a path the workspace records an intent at, so a path that names a deletion disk hasn't been given yet, or a conflict, keeps its spelling exactly, as one that names an entry does. Before the fix, on the case-folding test filesystem, a read of the deleted file's path took the file in again, and a materialize of that path with applyDeletions materialized nothing and left the file on disk. Two tests cover it. Yours: the read now answers that the file was deleted and takes nothing in, and the materialize deletes disk's copy. And the same file changed on disk after the deletion: the read answers that it's in conflict, and a sync of the path settles the conflict and the deletion, leaving one entry for the file.

Written by Tabitha-2696.

— Fiona-2757

…pelling, so a deleted file stays reachable (F194)

Answers Greptile's review of 5da80f9 on the pull request.

- entriesOf's has also counts a path the workspace records an intent at
  on the current branch: a deletion disk hasn't been given yet, a
  conflict, or a record that can't be read. A file deleted in the
  workspace while disk keeps its copy has no entry, only that intent, so
  once disk listed the file in another case, a path to it took disk's
  spelling: a read took the deleted file in again, and a materialize of
  that path with applyDeletions missed the deletion.
- Tests: Greptile's case, a file taken in from disk that disk then lists
  in another case, deleted in the workspace with its disk copy kept: a
  read of its path answers that it was deleted and takes nothing in, and
  a materialize of the path with applyDeletions deletes disk's copy. And
  the same file changed on disk after the deletion: a read of its path
  answers that it's in conflict, and a sync of the path settles the
  conflict and the deletion, leaving one entry for the file.
- Docs: has's, entriesOf's, parsePath's and diskSpelling's docs, the
  test file's header, and both changelog fragments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 2d3d5c2 into anima-research:main Oct 19, 2026
6 checks passed
tel-0s added a commit that referenced this pull request Oct 19, 2026
…llback, the all-except hold's channel included

What main brings since aae86b7 (0.22.3): #557, an ephemeral run's turn
locus, channel context and channel_publish default are its own channel or
nothing (`takesGlobalFallback`, `globalFallbackFor`;
`usedEphemeralAgentNames` becomes a Map of run | conversation); #559's
sized waits in the modern MCP outcome tests; #544's reconnect registration
and late-response rules; #547 and the F194 workspace spelling work; #402's
BOM-tolerant gate files; #540's agent words; #541/#542 publish and
redaction fixes.

Conflicts, all in src/mcpl/channel-registry.ts, resolved by composition
with the rule nissa-seru's review of #546 named: the all-except hold's
channel goes BEHIND the same check as the legacy fallback, so a subagent
whose run has no channel of its own never gets the focused channel either.

- buildChannelContext: main's `!== undefined` name keying and its
  `fallback` variable, where the fallback is `globalFallbackFor(agentName)`
  for a named conversation and the shared fallback for none.
- resolveLocus: `home ?? active ?? globalFallbackFor(conversationId)`.
  `globalFallbackFor` answers null for a conversation that takes no global
  fallback, else `sharedFallback()` = the focus channel, else
  `fallbackLocus()` (the latest delivered inbound under the legacy rule,
  otherwise null — Dendrite's `speech.locus`).
- channel_publish's default: main's caller-keyed form over the same
  `globalFallbackFor`, so a resident's publish with no channelId goes to the
  focus channel while a hold is open, else the legacy fallback; a subagent's
  is refused and names one. Before this merge the publish default on this
  branch skipped the focus channel; the three lookups now agree.

Two seams the build and tests found:

- framework.ts resumeAgent still called `.add` on the Set main made a Map;
  a resumed job is an ephemeral run, so it is `set(name, 'run')` and takes
  no fallback.
- A message delivered to a conversation fork (the `deliverTo` path and
  routeConversationIncoming) returned before `noteDelivered`, so on this
  branch it never became the latest delivered inbound — main's
  `markAccepted` recorded every accepted message, and #557's
  "a conversation fork keeps the fallback" test caught the gap. Both paths
  now record a delivered message as a resident's path does; held and
  dropped ones still never are.

Test adaptation: test/ephemeral-no-global-fallback.test.ts is about the
global fallback itself, so its fixture opts into `speech.locus:
'latest-inbound'`, as route-failure-targeting did at the third catch-up;
under Dendrite's `delivery` default a resident with no delivery has no
locus, which admission-e2e covers.

Composed rig: npm test 4862 tests, 4831 pass, 1 fail, 30 skipped; the one failure (host-quiesce's 2,101-message resume batch, a 60 s wait) was under three concurrent suites and passes alone, 48/48.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…nge marker, with no awareness gate

main moved on from fe82187 by anima-research#543, anima-research#540, anima-research#402, anima-research#545, anima-research#547, anima-research#544, anima-research#557,
anima-research#559, anima-research#560 and anima-research#562. Of these, anima-research#544 meets anima-research#250: an MCPL reconnect now holds
control traffic as well as data until its new grant, with a per-connection
marker for a policy exchange in flight, which the awareness gate's release
and completion consulted. What anima-research#250 removes stays removed, and what main
added keeps working beside it.

Conflicts:
- src/framework.ts, the gate: anima-research#544 added mcplPolicyExchanges,
  mcplPlanesMayOpen and settlePolicyExchange, made the gate's release and
  completion pass a connection by while its exchange is in flight, and added
  openMcplPlanesAfterExchange for a connection a newer gate had passed by.
  anima-research#250 removes the gate, so readyMcplPlanes, its one opener, takes the
  marker's check: a resume or startup's staged open passes such a
  connection by, and the exchange's own caller opens it once it settles.
  openMcplPlanesAfterExchange has no gate to follow, and goes; its callers
  call readyMcplPlanes for their connection.
- src/framework.ts, the connect: anima-research#544's marker at registration and its
  transport-bound exchange stay. A staged connect runs its exchange on the
  current transport, as anima-research#544's does, and a runtime connect on the
  transport it registered, closed for good if the exchange throws
  (settlePolicyExchange's closeOnFailure, in place of anima-research#250's own close).
  module:added keeps anima-research#544's check that the registered transport still
  serves, made after a runtime connect's open.
- src/framework.ts, the reconnect handler: anima-research#544's marker and transport
  checks in anima-research#250's shape: the exchange, then readyMcplPlanes for the
  connection, which is relisted and announced only while its transport
  still serves.
- src/mcpl/server-connection.ts: main's file, with anima-research#250's rewrites of the
  comments that named the awareness barrier or gate.
- README.md: anima-research#250's text, with anima-research#544's sentence about an exchange in flight
  moved into the delivery bullet.
- test/fixtures/reregistering-mcpl-server.mjs: main's, its header naming
  the three test files that use it.
- test/mcpl-awareness-barrier.test.ts stays deleted. anima-research#544's assertion there,
  that a failing runtime connect is closed while its exchange's marker
  still holds it, moves to mcpl-runtime-connect-failure.test.ts, where anima-research#250
  keeps those tests.

Outside the conflicts:
- test/mcpl-reconnect-registration.test.ts, anima-research#544's: its harness stood on
  the gate. Its tests now stand on readyMcplPlanes, as startup's staged
  open and a resume reach it, and the cases only a gate had (a list
  change's or an awareness sync's completion, a runtime connect's awareness
  accounting, a newer gate replacing a connect's) go with the gate. Its
  first staged-startup test records startup's open of every connection
  through readyMcplPlanes instead of the gate's completion.
- changelog.d: 0.23.0 folded modern-mcp-client.added.md,
  stop-reaches-every-cleanup.fixed.md and mcpl-reconnect-registration.fixed.md,
  so anima-research#250 no longer edits the first two. Its one-shot fragment says what
  changes from 0.23.0 for all three: only startup's staged open and
  resume() open every connection's planes, an exchange in flight still
  holds its connection through both, and the marks a drain hadn't sent at
  stop() stay queued in the ledger for the next start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 20, 2026
…ling and #544

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

One change no conflict showed: #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>
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