Repository navigation
fix(workspace): drafts take one spelling on a disk that folds case, and an entry keeps its own - #547
Conversation
…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>
[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
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.SummaryThe PR makes case-equivalent drafts share a spelling and keeps existing entries reachable by their own paths.
Limits explicitly acknowledged by nissa-seru:
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 |
| private entriesOf(mount: MountState): WorkspaceEntries { | ||
| const store = this.getStore(); | ||
| return { | ||
| has: (path) => store.treeGet(mount.treeStateId, path) !== null, |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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>
…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>
…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>
…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>
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,
deleteof its path deleted the other file, and a file deleted in the workspace came back on a read.Problem
Probed on
mainc0155d39on 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") andsrc/NEW.txt("second"), were two entries for one file.NEW.txtand skippedNew.txt: "not written: something appeared at this path since it was checked". The next materialize calledsrc/New.txta conflict.NEW.txtand deletedNew.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.txtanswered withNEW.txt's bytes;syncand a forcedmaterializeof that path, the remedies the conflict names, acted onNEW.txtand left the draft as it was;delete work/src/New.txtdeletedNEW.txt, andmaterializewithapplyDeletionsthen took it off disk.At
0a7fd161,mainjust 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, areadof the path took the file in again, and amaterializeof the path withapplyDeletionsmissed the deletion. Where disk had changed the file since the deletion, the read took it in rather than answering that it's in conflict. Withmainb82ba630d's sources, the two tests this change adds for it fail too. In the first,deleteof 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 at0a7fd161, on the folding disk.Two directory drafts,
Docs/a.txtandDOCS/b.txt, became one disk directory,DOCS/. The next sync announceddeleted work/Docs/a.txtandcreated work/DOCS/a.txt. Nothing was lost.A file draft and a directory draft of one name,
src/Itemandsrc/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), whichparsePathpasses it, so every tool's path goes through it:lowered,entryName), where the directory reached folds case;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), byholderOf, the nested-mount checks' own reading of a path.foldsCasetells whether a directory folds case without writing to it.startreads it for each mount's root where disk has it, at the real rootreadNestingplaces, before it sets anything.readNestingnow returns those real roots beside the nesting.listedNamealso 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
deleteand 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 amaterializeof its path withapplyDeletionswould miss the deletion. A conflict is settled by a sync of its own path the same way.Proj/would respellproj/inner/x.txt, whichmainrefuses as a nested mount's atproj/inner, and so would itproj/other/../inner/x.txt. So the step spells the path as disk does, and asksholderOf, 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 onmain. 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.Σand a word's lastςare bothσ,ſissandµ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 isSS, 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 fromi. A name's two normalization forms, and letters only a fuller folding joins, such asßwithss, stay apart too: no listing shows two such entries to be one file, and a disk that folds case needn't join them.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.entryNameskips a name equal to the one it has just compared. A name is compared again only where another came between its entries, asN.txtcomes betweenNandN/x.New.txtandNEW.txtare two files and stay two.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.startasks 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.Taken out while building:
lstatof that name, so it changed whattest/workspace-three-way.test.ts's race test observed. WhetherlistedNamefound a variant answers the same question, for a segment that has one.foldsCasefor 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...among the entries. No entry is named.., so the entries' walk stops there anyway.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 takeproj/other/../inner/x.txtout of a nested mount's directory, gave one directory two spellings in two tools, and withheld spellings no check needed withheld. AskingholderOfof both finished spellings is the checks' own answer.main. Whether the path names an entry is still read before the walk, and the entries' walk doesn't run for one that does.maingives.What callers will notice
write work/src/NEW.txtanswers withpath: "work/src/New.txt"where the draftsrc/New.txtexists. This holds forread,write,edit,delete,sync,materialize,ls,globandgrep, and forreadBinary,readBinaryFromDisk,readFileFromDiskandwriteBinary.main.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 ofmain,43df5134, the machine stayed at load 20–34, and three runs gave 78 to 88 ms and 173 to 201 ms at 50,000.2024, looks that segment up as given once more than onmain, 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 beyondmain, 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.tsgains these tests.On a disk that folds case:
materializeof that path withapplyDeletionsdeletes disk's copy;Σ,ς,ſ,µ,ᾈ,İ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;foldsCasefor 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;... 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:
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 ofmainb82ba630d:tsc --noEmitis 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
0b2c093bas withmain's code, each test passing or failing alike, but forworkspace-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 withmain's code and passed with this change; at the merge ofmain, run five more times on each side, it failed in 4 of 5 with this change and in 3 of 5 withmain'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 ofstate-registration-errorsunder--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 withmain's code, it reported and passed all 25 tests each time, as it did at0b2c093b.The comparison, red-first and the removals ran at
0b2c093b, which differs from this head only in a changelog fragment.Red-first, with
mainb82ba630d'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, sincemainlists 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:entriesOfaskingholderOfwhich nested mount holds a pathı.segment among entriesfoldsCaseby the directory's own namefoldsCase; an empty or missing rootfoldsCasetaking the directory holding one with no letter in its namefoldsCaselooking the flipped name up without following a linkfoldsCase; on ext4, the directories that tell case apartfoldsCasecomparing the root's own entry, not where it leadsfoldsCase; on ext4, the directories that tell case apartfoldsCaseof a directory not there yetstartreading the root's foldingstartchecking for a stop after reading itstartreading the root's folding before it takes callsentriesOfpassing the root's foldingparsePathpassing the entrieshascounting a path an intent is recorded atNot verified
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).foldsCasestopping at the filesystem's root: it decides only where that root can't be listed.foldsCaseby 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.Out of scope
By our reading of the code at this head:
ßandss, 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.foldsCaselooks up is a mount point.main, since the nested-mount checks compare spellings exactly, unless the entry whose spelling it takes is spelled as configured there.mainshowed the same for a file that disk comes to list in another case.mainand at0a7fd161, 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 onmain, 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
fix(workspace): take a path as disk spells it on a disk that folds names #525, which this follows. Its rule, that a path takes disk's spelling, now comes after an entry's own spelling.
This branch merges
mainb82ba630d, which brought fix(workspace): disk reads check the mount root; two opens skip FIFOs #533 and fix(workspace): a nested mount is its directory wherever disk puts it #535 after the branch's base. They met this change in five regions ofindex.ts: the./observe.jsimport, the nested mounts' field, beside which this change keeps each root's folding, and three instart, around what it reads before it sets anything. The merge keeps fix(workspace): a nested mount is its directory wherever disk puts it #535'sreadNesting, which now also returns where each root lies on disk, and reads each root's folding there. fix(workspace): a nested mount is its directory wherever disk puts it #535 also gives a nested mount's directory more than one place, as configured and where links lead, each with its own spellings, whichholderOfreads, and this change asksholderOf.feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463 at
39d0151d4, and feat(workspace): read says what the text is, stops a page before it would spill, and settles its path against disk first #465 at723580051, which merges it, each merge with this head without a conflict. Merged with0b2c093b, each typechecks, passes this file on the three disks, and passes the workspace's test files on ext4.antra-tess's feat(workspace): direct backend (default),
watchtool,write { append }#271 at6d3c4aa9balready conflicts withmain70ca7853ain 28 regions ofindex.tsand one ofsync.ts, and this change's lines fall in two of those inindex.ts: the./observe.jsimport, which gainsfoldsCaseand theWorkspaceEntriestype, and the module's fields, where it addsrootFoldsCase.feat(workspace): a mount's include names the files it holds, and is its boundary #536 at
49f3505f3conflicts with this head in nine regions ofindex.ts, one ofobserve.tsand three of this test file. Inindex.tsthey are:./observe.jsimport;start, what it reads before it sets anything, and what it sets after;readNesting's doc, its return type and its return. Each change returns the mounts' real roots beside the nesting, this one asreals, for each root's folding, and feat(workspace): a mount's include names the files it holds, and is its boundary #536 asroots;parsePath's doc and its call todiskSpelling. This change passes the workspace's entries, and feat(workspace): a mount's include names the files it holds, and is its boundary #536 keeps a path as given past a nested mount's directory;entriesOfand feat(workspace): a mount's include names the files it holds, and is its boundary #536 addsnestedDepth.In
observe.tsit's thenode:pathimport, and in this file the header comment, the import fromobserve.js, and where each adds tests.changelog.d/workspace-draft-spelling.fixed.mdWritten 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 findings5da80f9aanswers. Greptile's review of5da80f9afound 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.