Single crdt cutover - #1728
Open
jorgens wants to merge 103 commits into
Open
Single crdt cutover#1728jorgens wants to merge 103 commits into
jorgens wants to merge 103 commits into
Conversation
…chain The pin governed nothing. `.node-version` said 24.18.0, ~20 CI steps hardcoded `node-version: "24"` (floating across the minor line), the PR bridge used 22 — below the `engines` floor — and `.npmrc` claimed CI read the pin via `node-version-file`, which appeared in no workflow. Because engine-strict only enforces the floor, a newer local Node drifted silently. - Point all 23 setup-node steps (workflows + composite action) at `node-version-file: .node-version`. - Add scripts/check-node-version-pins.sh to `check:drift:guards`: rejects literal versions, a partial pin, a pin under `engines.node`, and a setup-node step with no version key. - Drop `ignorePatchFailures` — pnpm no longer recognises it and warned on every command. Fail-closed is pnpm's default; verified by corrupting a patch hunk and confirming ERR_PNPM_PATCH_FAILED. - Document the Rust + pkg-config prerequisite: packages/native-config is a Rust addon the workspace depends on, so `pnpm run check` ran `cargo test` and failed on any machine without it. - Correct the corepack instruction (gone from Node after 24) and gitignore the 13 MB of Excalidraw fonts every build regenerates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both seed-from-disk guards asked the XmlFragment whether a document already had content. Y.Text is the source of truth (precedent inkeep#38), so that question was being put to the derived replica: a document holding source bytes whose fragment had not been derived reads as empty, and the load seeds the file's bytes ON TOP of the live ones, concatenating disk content into a populated document. Both sites now refuse to seed when EITHER surface holds content, which is strictly more conservative than either check alone and covers divergence in both directions. Benefits: - Closes a content-duplication path in `loadManagedArtifactDoc`, whose guard read the fragment alone with no Y.Text fallback. - Makes the emptiness test agree with the truth contract the rest of the server already follows, so the two cannot drift apart later. - No behaviour change on the ordinary cold load: the seed is a paired write that populates both surfaces together, so the two conditions coincide there. Pinned by a `control:` row. - Removes the fragment coupling from the load path, which any change that makes the fragment derived-on-demand would otherwise have to carry. Tests: both new assertions verified non-vacuous (they fail with the guard reverted). Full server suite 8718 passed / 6 skipped; full monorepo `turbo run test` 12/12 tasks green; typecheck and biome clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Observer B rebuilt `Y.XmlFragment('default')` from `Y.Text('source')` on every
source-mode keystroke — a full markdown re-parse plus fragment rebuild,
synchronously on the main thread, unconditionally. Measured against the real
observers: exactly 1.00 parse per keystroke, parsing 1.00x the document each
time, at 72% of total keystroke cost. None of that output is read when nobody
is looking at the WYSIWYG.
This adds a demand gate. While no connected peer needs the fragment, Observer B
skips the rebuild and records that a derive is owed; the next fire that sees
demand — or `resumeFragmentDerive` on the awareness transition — pays it back.
Benefits:
- Removes the per-keystroke re-parse entirely for source-only editing, which is
where the cost was largest and the output least used.
- Turns the fragment into something derived on demand rather than continuously,
which is the same direction a local projection would take further.
- Gives the invariant watchdog a way to tell by-design staleness from a broken
bridge, on its own counter, instead of either alarming on normal operation or
being blanket-suppressed.
Three properties make it safe, each with a test:
- INERT BY DEFAULT. No `fragmentDemand` means the old unconditional behaviour.
Every existing caller omits it. Shipped behind `deriveDemandGateEnabled`,
default off, following the six existing bridge kill-switches — a change that
can make the fragment deliberately stale should not be inherited on upgrade.
- SUSPENSION IS HONEST. `deriveSuspended` is deliberately NOT `suppressDevThrow`:
that flag means "downstream site, keep going" and still records a violation,
which is right at a persistence fire. This one means "nothing has asked the
fragment to track Y.Text yet". Counted on `bridgeDeriveSuspendedDivergences`
so the documented identity `violations + suppressed` stays intact.
- SUSPENSION IS BOUNDED. Every suspension ends in a catch-up derive that
re-asserts the invariant at full strength; detach clears the flag. Suppressing
the watchdog is only defensible because the suppression always ends.
The demand policy fails safe in one direction: anything not explicitly
`mode: 'source'` — a missing field, an older client, an unrecognised value, a
throwing predicate — counts as demanding. A wrong "yes" costs the derive we
already pay; a wrong "no" shows someone stale content.
Agent writes are unaffected (they rebuild the fragment through the paired-write
primitives, not Observer B), but `resumeFragmentDerive` runs before the
before-snapshot in thread-manager so `changedBlockRange` cannot attribute
staleness to the agent's own edit.
Known window, documented at the call site and the main reason for the default:
the catch-up is triggered by the client's awareness update, so a pre-mounted
WYSIWYG can flash pre-edit content for about one round trip on the flip.
Closing it needs a client-side freshness handshake — a protocol change with its
own design.
Tests: server 8748 passed / 6 skipped; desktop 4343 passed (run alone —
worktree and keyring failures in the combined run were contention timeouts at
942s, both pass in isolation and desktop imports none of these surfaces);
typecheck and biome clean. Watchdog rows carry a `control:` proving the same
divergence still throws when not suspended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fragment" This reverts commit 97cb051. The demand gate was built to answer a question that has since been answered by reasoning instead: is the derived WYSIWYG fragment worth keeping fresh? The project is now collapsing to a single CRDT, so the fragment stops being a synced replica entirely and becomes a local projection. Every file in that commit was fragment-lifecycle machinery — the gate, the demand policy, the suspension flag, the watchdog's suspended-divergence branch, its metric, and two call sites — and none of it survives deleting the fragment. Reverted rather than left dormant: the gate was default-off and harmless, but carrying a kill-switch, a metric series, a policy module and a watchdog special-case through a migration that deletes the thing they protect is upkeep with no payoff at the end of it. The measurement that commit produced is the part worth keeping, and it does not need the code: Observer B performs exactly 1.00 full parse per source keystroke, parsing 1.00x the document each time, at 72% of total keystroke cost. That is recorded in the feature spec, and it is the number that justifies removing the derive rather than scheduling it. Kept from the same line of work: 14f29a6, which moved the seed-from-disk emptiness guards off the derived fragment onto Y.Text. That one is correct on its own merits today and is a step toward the single-CRDT target rather than scaffolding around the fragment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 0 of the single-CRDT migration, plus the pure core of Phase 1. `MarkdownManager.parseWithSourceMap()` returns the doc `parse()` would return plus a `PmSourceMap`. remark keeps a `position` on effectively every node; the mdast→PM handler layer dropped all of them, because a PM node has nowhere to put one. The map is built BESIDE the doc rather than in its attrs: attrs are schema, so they would sync into the CRDT, into `toJSON`, and into every byte-stability snapshot. A recorder threaded through a wrapped handler table costs one null check per node when nobody asks for a map. Three things the plan did not anticipate: - `dedentBlockJsxClose` is the one non-length-preserving transform ahead of remark, so it now reports its removals and the map re-adds them, composed with the BOM strip. The other guards are char-for-char. - `table` assembles its own rows and cells instead of delegating to `state.all`, so they inherited the whole table's span. A count-gated structural descent took node coverage from 78.9% to 97.0%. - Minting `commentBlock` positions fixed a byte-stability bug: with no position it reached `insertInteriorBlankRunParagraphs`, which could not measure the gaps beside it and silently dropped preserved blank runs on the way to disk. Measured over the 68-document docs corpus: 1918/1918 top-level blocks mapped from a parse fact, block table index-aligned with `doc.childCount` on every document, zero ordering or containment violations. `projection/block-splice.ts` turns a WYSIWYG edit into one `Y.Text` splice. The changed-block search is longest common prefix/suffix under `===`: PM nodes are persistent, so a transaction shares every node it did not rebuild — O(childCount) pointer comparisons, no `prosemirror-state` dependency, no step interpretation. Serializing one block rather than the document is required for cost (0.05ms flat vs 181ms at 488KB) and is also strictly better for byte stability, since a whole-doc serialize renormalizes blocks the user never touched. Tested on containment and re-parse fidelity, never against a whole-document serialize — that oracle disagrees with a correct splice about a tenth of the time and every disagreement is the oracle renormalizing. `commentBlock` was the last real-world top-level block without a position, so it was also the natural trigger for the server bridge's `missing-position` fallback. One test in map-driven-observer-a now asserts a comment block takes the splice path; the other drives the guard through a position-stripping stub so the guard itself stays covered. No behaviour change otherwise: `parseWithSourceMap().doc` is asserted equal to `parse()` across the package corpus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 1 and 2 of the single-CRDT migration, gated by
`PROJECTION_BINDING_ENABLED` (off). The fragment binding is still what
production uses; both paths coexist until the bridge goes.
`projection-binding.ts` binds ProseMirror straight to `Y.Text('source')`.
A local edit re-serializes only its top-level block and splices that
block's line range under the user's own origin — the same translation
Observer A does today, relocated to the client and narrowed from the
whole document to one block. Narrowing is required for cost (0.05ms flat
vs 181ms to re-serialize 488KB) and is also better for byte stability: a
whole-document serialize renormalizes blocks the user never touched,
which is a spurious diff on disk.
Two constraints had to be discovered rather than designed:
- A binding cannot install its document from `view()`. ProseMirror builds
plugin views inside the EditorView constructor, where TipTap's
`dispatchTransaction` reaches for a `this.view` that does not exist yet.
The first attempt dispatched there and the binding wrote the editor's
EMPTY starting document over the markdown. The projection has to arrive
as the editor's initial `content`, which is why `createProjectionBinding`
returns content, extension and undo manager from one call: told about a
different document than the editor was built from, the binding reads the
difference as a local edit.
- `MarkdownManager` carries its own `Schema`, so a projected document's
nodes have foreign NodeTypes. ProseMirror matches content by NodeType
IDENTITY: `eq()` was false between two documents with byte-identical
JSON, so every keystroke looked like a whole-document change, and nodes
inserted directly are silently dropped on the first incremental rebuild.
Convert through JSON on the way in, then adopt `view.state.doc`.
Typing never re-parses: `rebaseProjection` derives every block span
arithmetically after a splice. Pinned at 20 keystrokes → 20 splices → zero
document parses; an outside write costs one.
Phase 2 falls out. `shared-undo-manager.ts` holds one `Y.UndoManager` per
document over `Y.Text`. Source mode reaches it by handing it to `yCollab`
(y-codemirror adds its own sync origin when it installs); WYSIWYG reaches
it because the projection writes under `PROJECTION_WRITE_ORIGIN`, and
ships the Mod-z keymap alongside the write for the same reason. This is
the target wiring, not the interim hack the plan warned off — that was
injecting a manager into `Collaboration`, which is fragment-based and is
being deleted. `trackedOrigins` includes `null` so source-mode undo is
byte-identical to today with the flag off.
`cross-mode-undo-projection.test.ts` runs both surfaces for real — a
CodeMirror view bound by yCollab, a ProseMirror view bound by the
projection, one Y.Text: LIFO holds in both authoring orders, undo and redo
reach both views, and a two-line source frame survives an interleaved
WYSIWYG edit and retracts whole. That last one is defect 1's exact shape.
Under the flag, four extensions drop out with the fragment they service:
`collaboration`, `collaborationCursor` (resolves through ySyncPluginKey),
`bindingStalenessGuard` (guards a Y→PM apply half that no longer exists)
and `walkCurrency`. Both arms are pinned by test.
Two governance suites caught real omissions: the origin-undoability sweep
wanted a ruling for `PROJECTION_WRITE_ORIGIN` (client-editor-um), and the
window.__ STOP rule flagged the dev-toggle docstring — reworded to prose
rather than allowlisted, since that list attests to a verified DEV gate and
this module only reads the global.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dev:electron` runs under turbo's strict env mode, so an undeclared variable is dropped before electron-vite sees it — the flag would have stayed off while looking like the projection path had simply changed nothing. Declare it in that task's passThroughEnv, and record all three routes (browser dev server, desktop, `.env.local`) in the spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by typing on the flag path, which is exactly what that step is for: Enter did nothing, while Shift+Enter worked. Enter makes an EMPTY paragraph, and markdown has no way to write one — a blank line is only expressible as a wider gap between two blocks that themselves emit. So `serializeBlockRange` returned '' for the inserted block, `computeBlockSplice` keyed its branching off that empty string and took the DELETION path, that path found an empty source range and returned null, and null meant "cannot place this edit", which rebuilt the document from the unchanged markdown. The user's new line vanished as they made it. The branching now keys off the shape of the edit — insertion, deletion, replacement — and off whether the replaced blocks occupy bytes, never off whether the serialized text happens to be empty. A block that emits nothing is held in the projection with a zero-width span (so the block table keeps one entry per document block, which is what the rebase and the next splice's indexing rely on) and written as nothing at all: no CRDT transaction, since an empty one would still wake every observer and land an undo item that undoes nothing. It materializes into bytes the moment it gets content. Two supporting fixes: - `blockRangeToSourceRange` no longer widens a zero-width range to its enclosing line. A zero-width span is an insertion point; widening it made the next edit overwrite the neighbour the empty block sits against. - A point-write's anchor and its blank-line separator are now one decision. Choosing the side independently put the separator on the far side of the anchor from the neighbour it was measured against, landing inserted text inside that neighbour's gap — caught by the existing mid-document insertion test. Also recorded in the spec's traps: a parse of what the projection wrote is not structurally identical to the document that wrote it (a stranded leading space comes back carrying a `sourceLiteral` mark), and interior blank runs authored in WYSIWYG still do not reach the markdown — unlike the empty paragraph, those DO have a spelling, so that one is a real gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second report from typing on the flag path: several blank lines added in WYSIWYG showed there, collapsed to one in source mode, and reappeared in WYSIWYG — the last part only because the editor instance is cached and still held blocks the CRDT never received. Blank paragraphs emit no markdown at any count, so serializing the changed block yields the empty string whether there is one blank or five, and the run was held as unwritable. But markdown does spell them, as the gap between the blocks on either side: `insertInteriorBlankRunParagraphs` reads N blanks out of a gap of N+2 newlines, and the doc-edge pass reads them out of N+1 trailing newlines from MIN_CARRIED_EDGE_EMPTIES up. So the write is arithmetic on newlines between the run's emitting neighbours rather than serialization of its blocks. That is also byte-minimal — only the newlines between two blocks are rewritten, never the neighbours themselves, so no untouched block gets renormalized. The run is re-derived from the current document rather than taken from the changed range: adding one blank line to an existing run changes one block but has to rewrite the whole run's gap. Two cases stay held and unwritten, both deliberately. A single TRAILING blank is below the doc-edge floor, where an empty paragraph is indistinguishable from the type-here affordance the editor renders after the last block — the parse side refuses to carry it, so this side must not write it. A LEADING run needs the boundary-capture path and is not handled. `rebaseProjection` declines a whitespace-only rewrite: those blocks occupy no bytes and their parsed positions are not reproducible by the newline arithmetic, so the caller re-derives from the markdown instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding blank lines in WYSIWYG worked after the last fix; deleting them did not. Removing a blank reaches the write path as a deletion of a block that occupies no bytes, which the ordinary deletion branch declines — so nothing was written, the editor showed one fewer blank line than the markdown held, and the next re-projection handed the deleted line back. The gap arithmetic already covered this; it was only reachable from the add side. Route any edit that adds OR removes blank paragraphs through it, before the deletion branch. A count of zero needs no special case: the gap becomes the ordinary two-newline separator. Emptying a paragraph of its text is a gap edit as well, not a block deletion — the block is still there and still renders as a line, so it becomes the blank line it now looks like rather than disappearing from the markdown while staying in the editor. A trailing run that falls below MIN_CARRIED_EDGE_EMPTIES is now written as NO run rather than left alone. Leaving it would keep more blank lines in the markdown than the editor shows, and the next re-projection would give back a line the user had just deleted. One trap this exposed, and the reason three existing tests went red before it was handled: a gap rewrite that computes back to the bytes already present must be DECLINED, not returned. A below-floor trailing run does exactly that, and reporting it as a write makes the caller record a write it did not make — the block table ends one entry short of the document, and the next keystroke indexes past the end of it and is lost. Declining sends the caller to the zero-emission hold, which gives the block a zero-width span and keeps the two aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what landed and where (§5), the invariant that generates most of the trap list, the manual-test checklist that is the actual gate on Phase 3, and the verified suite state. Two things the next session should not have to rediscover: Every bug found so far came from typing on the flag path and none from the test suite — three in the first session of manual use, all the same shape: the ProseMirror document is strictly more expressive than markdown, and anything it can hold that markdown cannot spell needs an explicit answer. The checklist marks what has been exercised and what has not; lists, tables, paste and JSX components are untouched and are where the next one is likely to come from. And `map.blocks.length === doc.childCount` is the invariant behind those three bugs. Its symptom shows up one keystroke LATER than the edit that broke it, because a misaligned block table loses the NEXT edit rather than the one that caused it. Also un-exports PROJECTION_BINDING_ENABLED, which nothing imported once the flag moved behind `projectionBindingEnabled()` — restoring exact knip parity with the pre-change baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot spell Two pieces of the single-CRDT projection, both in core because both surfaces need them. `source-blocks.ts` computes top-level block ordinals from markdown alone. That coordinate was previously derived twice — the app from an mdast parse, the server from the `Y.XmlFragment`'s children — and the migration deletes the fragment, so this becomes the surviving definition. Dependency-light on purpose (mdast positions and string slicing, no ProseMirror) so the server can index ordinals without building a document. `alignProjectionToDoc` re-holds trailing empty paragraphs with zero-width spans. An empty paragraph has no markdown spelling, so any rebuild re-parses bytes that cannot reproduce it and the block table comes back one entry short of the document. That breaks `map.blocks.length === doc.childCount`, the invariant every splice indexes through, and both consequences are silent: tail edits become unplaceable and are discarded, and `rebaseProjection` refuses outright so every keystroke falls back to a whole-document parse. Found by hand — Enter twice out of a bullet list, then type, and the text lands at the end of the first bullet. The suite could not see it because every fixture ends in a paragraph and none rebuild after an Enter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3's first port. `snapshotBlocks` read the `Y.XmlFragment`'s children to diff which blocks an agent write changed; it now parses `Y.Text` through core's `sourceBlockSnapshot`. The app's `block-spans.ts` re-exports the same core function, so app and server share one definition of a block ordinal rather than two that happen to agree. Block identity is the block's own source bytes, which is sharper than its plain text: a link-target rewrite changes the bytes while leaving the text identical, and the old text-based identity would have reported no change and flashed nothing. The cost is a parse per snapshot where the fragment read was a walk. Bounded: twice per agent thread write, on a path that already parses the payload, never on a keystroke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urface Phase 3's second port. `replayBufferedContent` compared both CRDT surfaces of a pre-recycle replica against the server to decide which held the un-drained edit; under the projection it reads `Y.Text` alone and skips the fragment rebuild entirely. The plan said the `ok-buffer-replay-diverged` arm "becomes unreachable" once there is one surface. That was wrong, and the distinction matters. The fragment was not a second opinion about the edit — it was a standing record of the ACKED BASE, because only the server's Observer B ever wrote it. Removing it does not make the arm unreachable, it makes it UNDECIDABLE, and an aged buffer then splices straight over content the server rebuilt from disk. So the base is now recorded deliberately: snapshotted at each `synced` alongside the `lastServerSyncedSV` already captured there, carried on the buffer, and persisted through the outbox so a tab-crash recovery keeps its witness. Deliberately NOT taken beside `lastDiskAckedSV`, the stricter watermark — a disk-ack lands asynchronously and describes an earlier state than the doc holds when it arrives, so reading text there would record content the server never acked. Two conservatisms, both failing toward the delta fallback, which MERGES where a content splice REPLACES: no base recorded declines rather than splicing blind, and a base coarser than the fragment's costs a refusal rather than a wrong write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects found by typing, neither visible to the suite. A `jsxComponent` serializes from the `sourceRaw` slice captured at parse time, not from its children, so a WYSIWYG edit inside one emitted the stale capture and was silently discarded — it stayed on screen and vanished at the next mode switch. `deriveStructuralFreshness` is what notices the divergence and re-derives; under the bridge the SERVER serialized and its manager has always had the flag, so moving the serialize onto the client lost the coverage with the move. The projection now takes its own manager rather than the clipboard's, which also backs copy/cut/paste where re-deriving is not obviously wanted. `Y.UndoManager` merges frames by elapsed time alone — no origin check — so once both surfaces write the same `Y.Text` a source edit and a WYSIWYG edit inside the 500ms capture window became ONE stack item and a single undo retracted both. Every existing row in `cross-mode-undo-projection.test.ts` calls `breakFrame()` between edits, so none could see it; the product had nothing playing that role. `handleModeChange` now closes the frame at the mode boundary — a natural boundary for the user, and reaching the other view requires passing through it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… root path Two subsystems that read `Y.Doc` state but had been wired through the bridge. `attachQuiescenceTracker` was called from inside `setupServerObservers`, so a document the bridge declined never got one. Its counters start equal and `isDocQuiescent` is `settledGen > lastUserTxGen`, so an untracked doc reports NOT quiescent forever — and persistence gates every write on exactly that, deferring each store indefinitely. Tracking reads transactions only and has nothing to do with the fragment; it now attaches per-document from the extension, outside every bridge skip. `relative(contentDir, contentDir)` is `''`, and `ignore` throws "path must not be empty" on it, which aborts the whole parcel batch and silently drops every other event in it. A raw watcher event on the content root reaches the filter that way. `contentRelativePath` already guards the same case for the folder index; both `ContentFilter` implementations now do too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The projection is the only client path and the markdown bridge is never attached. `Y.Text` is the only live CRDT. - `projectionBindingEnabled()` returns true unconditionally; the flag constant and both dev-only override channels go with it. - The observer extension never attaches observers. What survives there is the per-document quiescence tracker, which was never bridge logic. - Port 3 — both emptiness guards read `Y.Text` only. The fragment halves asked a derived replica whether a document had content; with nothing deriving it, they could only ever answer wrong. - Port 4 — persistence no longer runs the bridge-invariant check or its repair arm. Left unguarded it reported divergence on every write and took the repair each time, minting a checkpoint into the user's version history and rebuilding a replica nothing reads. Nothing about the bytes on disk changes: `Y.Text` was already the source of truth (precedent inkeep#38). `OK_DISABLE_BRIDGE` was scaffolding for the manual pass and is gone from `turbo.json` with it. The observer machinery, its tests, and the fragment-path client modules are still present but unreachable; they come out next, along with the four unit tests that assert the fragment arm and now fail by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uites The four defect-characterisation suites were untracked and load-bearing for verification; one of them failed `biome check` on formatting and had been blocking `pnpm run lint`. Formatted and committed so they stop being one `git clean` from gone. Spec updates from the manual pass: - The §8 dev command now sets both halves. The old one satisfied neither the requirement stated directly beneath it nor any way of satisfying it — there was no server-side switch at all until one was added. - Records the symptom that led there, because it points anywhere but at the bridge: typing jumps the caret to the previous edit point while Enter works perfectly. Enter writes ZERO bytes, so the drain never wakes; only byte-writing edits lose the race. - Ports 3 and 4 reclassified. Called ports, then "behind the manual gate"; both wrong, and the second wrongly implied the pass could be completed without them. - Suite reliability: grep `AssertionError` before believing a failure. Every spurious failure here is a timeout, and timeouts are self-amplifying — the same 20 files took 1,211s and failed 4 at the default budget, 249s and failed none at 300s. Raise the budget to triage; do not bisect the file list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in 32 upstream commits. Four land on this work: - e94c95a enforces a default-deny no-comments policy. Its sweep stripped the prose from every file this branch touches, so most conflicts were our comments against upstream's deletion of them. Resolved toward the code. - a749cea drops TipTap's TrailingNode. Two knock-ons resolved here: `comparableChildCount` takes upstream's form, and the edge-blank floor is now split, so the trailing gap write reads MIN_CARRIED_TRAILING_EMPTIES. - 280bc35 adds a source-undo flip tracker that clears the undo stack on return. It is wired to the shared manager rather than a source-only one, which means its clear() now reaches both surfaces. Preserved as-is: it guards against destructive undo, and whether the single-CRDT path still needs it is a question for a test, not for a merge resolution. - a8dae21 touches sync status, which the wedged-document work will revisit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream split MIN_CARRIED_EDGE_EMPTIES into a leading floor of 2 and a trailing floor of 1, because dropping TrailingNode makes a single trailing blank distinguishable from the click affordance. Adopting the trailing floor of 1 in the block splice broke the projection. The write path depends on a lone trailing empty paragraph staying unwritten until it gains content: with the floor at 1, Enter wrote a blank line, and typing into that paragraph then materialized the block without reclaiming the line. "Enter, type, Enter, type" ended with a trailing newline the document did not have, and the doc/source equality assertion beside it failed. The two floors answer different questions — one is disk carry fidelity, the other is whether an editor-held block gets bytes — so the splice keeps its own named constant. Whether the projection can now adopt 1, and reclaim the line on materialization, is a separate change with its own tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream's no-comments policy (e94c95a) rejects prose in source files at error severity, and the projection modules carried ~640 lines of it. The knowledge was load-bearing, so it moved rather than went: §10 of the migration spec is now an architecture reference for the coordinate system, the byte map, the write path, the binding, undo and replay attribution. Removal was driven by the policy's own predicate (analyzeFile from lint-plugins/no-comments) rather than by hand, so the cleanup and the gate agree by construction. `pnpm run lint` is clean, including no-comments. Suites unchanged: core 3901 passed; app 15 failed, byte-identical to the pre-merge failing set; server 21 failed, unchanged across the edit. Those reds are the fragment-path arms the cutover stranded, and are Phase 1/2 work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Phase 0 comment sweep removed every prose comment, including a handful that the policy's own allowlist admits. CONTRIBUTING documents the class: a comment beginning STOP: (a cross-file contract a reader must not break) or WARN: (a sibling that silently drifts if this changes) is a contract-marker, not prose. Five come back, each a constraint whose violation fails silently: - block-splice: map.blocks.length === doc.childCount, the contract every splice indexes through. A violation loses the NEXT keystroke, not this one. - projection-binding: the write stays one delete plus one insert; a character-minimal diff reintroduces the stale-anchor content-loss class. - projection-binding: MarkdownManager owns a separate Schema, and ProseMirror matches content by NodeType identity, so nodes inserted without the JSON conversion are dropped on the first incremental rebuild. - shared-undo-manager: PROJECTION_WRITE_ORIGIN is an identity to track, not an origin to write under. - block-spans: comparableChildCount is a tripwire, not a proof of alignment. A `//` marker is one line by rule — the linter says so directly — so all five are block comments. The rest of the prose stays in the spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature-specs/ is a local working area — the migration guide, the research record, and the drafts for approaching upstream. None of it belongs in the public mirror, which is generated from an allowlist and has no such directory. Ignored rather than deleted: the notes stay on disk and stay useful, they just stop being something a branch can carry into a pull request. Prior commits that added or edited them were rewritten out of this branch's history; the versioned copy lives on single-crdt-specs-archive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md has linked README.md#running-desktop-with-browser-access since c06b0d5, but the section it points at was never written — the link has been dangling ever since. Documents why `pnpm --dir packages/desktop run dev` cannot serve a browser client (electron-vite sets ELECTRON_RENDERER_URL, the window manager then sends no reactShellDistDir, and the server drops the `ui` capability), and the build-then-launch path that does. Includes the ELECTRON_RUN_AS_NODE=1 trap that VS Code terminals inherit, which fails the launch at the first electron import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2a of the single-CRDT migration: take the fragment off the shipping
write path. Nothing reads it any more — each client derives its own
ProseMirror document from `Y.Text` locally — but the server was still
building and diffing one on every store, every agent write, every
file-watcher write, every rollback, and on every asset event touching a
document that referenced the asset.
Y.Text remains the source of truth for the bytes that reach disk, and that
path is untouched. Byte stability was verified across headings,
source-form delimiters (`__foo__`, `~~~`), frontmatter, CRLF, trailing
blank runs, doc-start thematic breaks, tables and nested lists.
bridge-intake:
`composeAndWriteRawBody` and `replaceRawBody` become Y.Text-only. With no
fragment to feed, the whole-document parse they performed had no consumer
either, so an agent write is now a line-aligned diff and nothing more.
Their embed-resolver, pre-parse and loss-detector parameters went with it,
which narrows `applyExternalChange`, `applyAgentMarkdownWrite`,
`applyAgentUndo`, `createExternalChangeHandler` and
`reconcileDiskBeforeAgentWrite` in turn.
`deriveFragmentFromYtext` was purely a fragment derive, so agent-undo is
now `um.undo()` inside the transact and nothing else. The paired-write
enforcement gate learns that `Y.UndoManager.undo()` is itself the write
on that path rather than a bypass of one.
persistence:
Drops the `json` half of `captureDocSnapshotForPersistence`, the
`assertBridgeInvariant` call (side-effectful: it incremented counters,
emitted tolerance events and wrote JSONL evidence on every store),
`reconcileFragmentNow`, `checkpointBeforeReconcile`, the always-zero
`fragmentChildren` fields, and the fragment conjunct in the seed-from-disk
guard. `BRIDGE_DISABLED` goes with them: its own note said it lasts until
the last fragment read comes out, and this was the last one.
client:
`setupObservers` and its provider-pool wiring are gone — both callbacks
were already empty. `observers.ts` reduces to the `ORIGIN_*` identities
and `markUserTyping()`. The buffered-replay path loses its fragment arm,
which also retires one of the two remaining `projectionBindingEnabled()`
call sites.
The paired-intake loss detector is removed from the intake primitives. It
compared a pending fragment serialization against arriving bytes to catch a
never-propagated keystroke; with one replica that class of loss cannot
exist, so the comparison has no meaning. The checkpoints on those paths are
untouched and still capture at-risk content.
Checkpoint metadata keeps `fragmentChildren` as an optional field and the
parser accepts entries without it, so timeline entries written before this
change stay readable and restorable.
Known consequences, measured against a `3d96b9fe` worktree:
- Two back-to-back API writes to one document can now land inside a
single contributor-flush window and produce one history entry instead
of two. The writes themselves are correct; only history granularity
changes. Reproducible, and a 500ms gap between the writes restores the
old count.
- `persistence-divergence-realign` no longer emits its `detector-trip`
ring event. The checkpoint still fires and still contains the at-risk
line.
- Nothing re-resolves embeds server-side after an asset event. That
refresh already reached no client under the projection binding, so the
gap predates this commit; the dead loop is merely removed here.
Suites, each attributed against a measured baseline rather than assumed:
core and desktop clean; app unit and DOM match their baseline failure sets
exactly; the 55 new server failures and the integration deltas are confined
to bridge, observer, pre-drain and paired-intake suites that Phase 2b and
2c delete wholesale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`skill-restore` wrote two skill versions back-to-back and immediately expected two history entries. It passed only because the pre-2a write path was slow enough to push the second write past the contributor-flush window. With the fragment derive gone, both writes land inside one window and share a single entry — confirmed permanent, not merely delayed: two back-to-back writes never reach two entries, even given 30s to do so. That batching is accepted behaviour, so the test is what changes. It now waits for each write to reach history before making the next one, which is what it actually needs to have an earlier version to restore *to*. The contract under test — restore reverts the source to an earlier version — is unchanged and no longer depends on how long a write happens to take. The changeset gains the user-facing half: rapid successive writes to one file can share a version-history entry. Nothing is lost, and edits seconds apart still get their own; there are simply fewer, larger steps to step back through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2a took the ProseMirror fragment off every shipping write path, leaving the bridge apparatus running against a tree nothing wrote to. This removes it. Deleted: server-observers.ts, bridge-watchdog.ts, pre-drain-discriminator.ts, map-driven-splice.ts, bridge-loss-suppression.ts, and the 39 suites and rigs that existed to test them. The blast radius ran through two shared rigs -- bridge-race-rig.test-helper.ts into pre-drain-wired.test-helper.ts -- which is why it reaches further than the module list suggests. server-observer-extension.ts loses its last BRIDGE_DISABLED and collapses to the quiescence tracker, its only remaining job. That tracker is the persistence settle gate, not a bridge remnant: a document with no tracker never reports quiescent and so never persists. server-factory stops reading the three bridge guard keys that only fed the disabled path; they still parse. PairedWriteOrigin, isPairedWriteOrigin and OBSERVER_SYNC_ORIGIN move to a new write-origins.ts ahead of the deletion, so the thirteen importers that needed only those identities no longer reach into the machinery. bridge-loss-detector.ts and loss-capture.ts are NOT deleted, against the plan's list. Both have live non-bridge consumers: persistence.ts compares live content against disk at three sites through detectPairedIntakeLoss, and the CLI's `ok diagnose` bundle reads the loss-capture ring. Two files repaired rather than deleted. bridge-loss-detector.test.ts drops to its three pure-function describes -- everything else seeded a fragment, and it was already failing at HEAD on a deriveFragmentFromYtext import 2a removed (test files are excluded from typecheck, so nothing caught it). The extension's bridge-disable test is replaced by a quiescence-lifecycle test; its "bridge is declined" assertions had gone vacuous. The 19 bridge-era metrics counters are marked @deprecated rather than removed: the payload shape is unchanged and knip still reports them, so the irrelevance stays visible instead of being silently retired. The six persistence reconcile-loss counters are deliberately left untagged -- those measure an event that still happens whose instrumentation 2a dropped, which is a Phase 3 repair, not a retirement. Server suite: 76 failures -> 19. Every remaining failure is pre-existing -- 14 in the 3d96b9f baseline, and 5 (qa-watcher-intake-lens, reconcile-intake- loss) verified by running them at HEAD. Typecheck 11/11, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt arm projectionBindingEnabled() returned a constant true, so buildPatternDConstructorOptions took the projection branch on every shipping path and the fragment arm was already unreachable. The seam and that arm are gone, and `projection` is now a required field on BuildEditorOptionsArgs -- the fragment path is unrepresentable rather than merely unreachable. Deleted with it: the initProseMirrorDoc prewarm walk and its prebuiltMapping, buildPrewarmBoundCollaboration, the @tiptap/extension-collaboration import, walk-currency-extension.ts and binding-staleness-guard.ts with their onWedged / WedgeDetail plumbing, and the two `...(projection ? [] : [...])` arms. The four app suites that existed only to exercise the deleted arm go too: pattern-d-walk-currency, pattern-d-schema-identity, and the two module tests. Three of those four were already failing in the 3d96b9f baseline. walk-currency-test-harness.ts is NOT deleted, against the plan's list. The sweep the plan asked for found 30 importers, most of them unrelated to the bridge -- paste, autolink, math input rules, slash commands. It is a mixed file: generic JSDOM and clipboard rigging plus fragment seeders. Only the six exports that lost every consumer were removed, same judgement as bridge-loss-detector.ts in 99676c2. comparePmStructural, isParseEquivalentBridge and BridgeMergeContentLossError are NOT deleted, also against the plan. BridgeMergeContentLossError is live: merge-three-way.ts throws it at five sites and http/catch-errors.ts catches it under a STOP rule, all external-change merge rather than the ProseMirror bridge. Neither of the other two is knip-reported, and isParseEquivalentBridge feeds the integration harness's assertBridgeInvariant, which 2c retires -- removing its dependency first would leave a broken intermediate. The mount-promise pre-construct yield stays. Its docblock justified it by initProseMirrorDoc, but construct() now calls createProjectionBinding -> buildProjection, a synchronous whole-document parse, so the yield is more load-bearing than before, not less. Only the stale rationale changed. Checkpoint mint sites were left alone. Production has zero mint sites for the six dead bridge kinds already, so the plan's instruction was satisfied before this commit; removing mint capability from shadow-repo's params union instead broke 21 server tests that use those kinds as fixtures for generic checkpoint machinery, GC budget isolation included. Reverted. The two ORIGIN_* identities move into the integration harness that is their only consumer, and the now-stale rows come off origin-undoability-sweep's contract table. walkYTextItems is un-exported rather than deleted -- agent-activity.ts uses it three times internally. Knip: 75 -> 72 unused exports, exactly those three, nothing newly orphaned. bridge.deferGuard, bridge.fixedPoint and bridge.preDrain descriptions now record that they are deprecated and unread; they still parse. backgroundThrottle, flushOnHide and lossDetector are untouched -- all three still gate live behaviour. renderCursor is deleted with the yCursorPlugin arm. Phase 5 plans to reuse it and will need it back from this commit's parent; it resolves through ySyncPluginKey, so the arm could not be re-enabled in place regardless. Typecheck 11/11, biome and lint clean. Server 8,751 passed / 19 failed and desktop 4,385 passed both match the recorded figures exactly; conversion holds at 80/25, so byte stability is unchanged. App unit drops from five baseline failing files to one pre-existing. Integration is 248 failed / 76 files against a 220 / 69 baseline that predates both 2a and 99676c2; every new file carries a fragment-era signature (empty-fragment assertions, the harness bridge invariant, ENOENT on server-half deletions), but no HEAD baseline was measured, so that attribution is by signature rather than by diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 upstream commits, 4 content conflicts. package.json test:e2e: kept the branch's list and added upstream's new agents-panel-reload-visibility.e2e.ts. Upstream's list still names observer-a-multi-client and mode-switch-ordinal-divergence, which the branch removed; the membership guard passes. timeline-and-recovery.mdx: took upstream's new paragraph on automatic recovery entries and kept the branch's undo paragraph. external-change.ts: kept upstream's insert-dedup-skipped warning and called applyExternalChange without the embed resolver and bridge loss reporter. persistence.ts (1a8b736): took the absent-base tripwire baseline (baseBytes from duplicationBaseline) without the fragmentChildren log field, which read the deleted fragment. Tests 1a8b736 added against the fragment, judged case by case: - Ported (the behaviour survives; the fragment was only how the test made an edit): the two absent-base tripwire cases in persistence-tripwire-paste.test.ts now double the document through replaceSource, and eleven server-factory.test.ts cases (refused-store rescue, destroy() and teardown rescues) append their unflushed edit to Y.Text instead of inserting a fragment paragraph. - Removed (bridge-only): the four failed disk-authoritative ingest cases. They forced applyToDoc to fail by making getXmlFragment throw BridgeMergeContentLossError; on this branch the ingest is a Y.Text splice that never reads the fragment, and two of them failed between the Y.Text write and a fragment rebuild that no longer exists. The catch they covered stays as upstream wrote it. Both files pass, 182 / 182. Typecheck, pnpm run lint and the e2e membership guard pass. Knip adds one finding, rawSegmentMatchesValue in wiki-escape.ts, which upstream also exports and uses only inside that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gnature Upstream's new stale-external-write-gate case (1a8b736) and two reconcile-own-flush-window calls pass upstream's argument list, with an embed resolver and a bridge loss reporter before the conflict authority. The branch removed those two parameters, so the authority landed in an unused slot and conflicts was undefined: the new case threw on conflicts.raise, and the two older calls passed only because their path never raises. The calls now pass the authority fifth. The test still makes sense without the fragment: it pins that a reconcile against a blockless acknowledged base is refused, raises a reconcile conflict and keeps the live document. Both files pass alone, 25 / 25; this was the one server unit red at e97b44e. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four comments described the architecture this branch removed: - RawMdxFallbackCMView: a data flow routed through y-prosemirror and a CRDT ProseMirror change; the WYSIWYG now writes Y.Text through the projection binding. - apply-by-prefix-suffix: cited precedent inkeep#10, "XmlFragment-authoritative, Y.Text mirrors". - persistence canonicalizeForEphemeralBaseline: "fragment must catch up". - agent-sessions: said isPairedWriteOrigin gates paired writes and that paired: true makes observers short-circuit; the observers are deleted and isPairedWriteOrigin has no production caller. Deleted rather than rewritten, per the no-comments policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A remote Y.Text change re-parsed and replaced the whole document on every client: about 0.7 s per keystroke at 488 KiB, and 65-76% of the reader's main thread at 64-128 KiB with one peer typing (main: 0%). - core reprojectChanged reparses a window of top-level blocks. It is trusted only when the preprocessed text splits cleanly at the window's edges (JSX tags pair document-wide) and both anchor blocks reparse identical; otherwise the full parse runs. - JSX attributes no longer carry parse positions, which made every component below an edit a different node. - A remote edit replaces only the changed blocks, synchronously, so undo and caret carrying are unchanged. - Remote carets and the agent flash share the binding's resolver. Two SelectionAnnouncer cases and one list-drag case that were test.fails now pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A peer keystroke on a ~480 KiB document must replace one block, parse no whole document, keep the caret, and cost under a tenth of one full parse; a full-precision lookup after a local keystroke must stay under the same budget and equal a fresh parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… path After the peer-edit fix, a book-length document still froze after every pause in typing: the word count and the lint pass each parsed the whole document, lint twice, and the chunk wrapper rebuilt a decoration for every block on every state. - Word counts run in a worker, with the same function on the main thread where no worker is available. One pass is in flight at a time; a change during it causes exactly one follow-up. - Lint takes block spans from the binding's projection when it matches the source, and parses as before otherwise, including every source whose strict parse fails. - Chunk wrappers are plugin state, mapped through each transaction and rewrapped only in the changed top-level blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The word-count worker died on load and every count fell back to the main thread, which is what it was meant to leave: `decode-named-character-reference` reads entities through `document.createElement` in the build bundlers pick under the `browser` condition, and a worker has no `document`. The page saw an uncaught error per pass. Aliased to the package's own default build, which decodes from a table -- what Node and the package's `worker` condition use. Vite 8 has no worker-scoped resolve options, so the alias is global; same decoder, same results. Resolution falls back to null, leaving the browser build alone, rather than breaking the bundle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
knip reported it as an unused export; callers read the fields structurally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 upstream commits, 2 conflicts, both content, both in core and both adjacency only: upstream's new parser-reservations module (d53fece, PRD-8392) lands its export and its import next to the branch's pm-source-map ones. core/src/index.ts: kept both exports, upstream's isMutatingParserReservation ahead of the branch's PmSourceMap types, which is where the path sort puts it. core/src/markdown/pipeline.ts: kept both imports on the same rule, and kept the branch's preprocess helper at both call sites. Upstream replaced the inline chain encodeEntityRefs(protectFromMdx(encodeBackslashEscapes(s))) with protectParserSource(s) and dropped the three imports; preprocess held that same chain, so its body now calls protectParserSource and the helper keeps carrying dedentEdits into dedentBlockJsxClose, which upstream's call sites do not do. No invisible conflict this time: upstream's nine commits touch acp launch and release-age, generated-index literals, startup skill reconciliation and four release resets, none of which reach the removed bridge helpers. Typecheck 12/12 and lint both pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream's d53fece (PRD-8392) makes the parser honour an escaped punctuation mark instead of dropping it. Typing a URL without a trailing boundary authors `https\://a-side.com` -- it did so before this merge too -- and that escape now survives the round trip, so the receiver projects literal text where it used to project a link. The branch's contract is unchanged: a reader still renders exactly what the bytes parse to, and still authors nothing. Only the bytes' meaning changed, so the two cases now assert the rendering that meaning produces. - Both peers render the boundary-less URL as literal text; only the boundary-typed URL, which authors bare bytes, becomes a link. - The Y.Text waits pin the escaped form, so a regression in either the write path or the parser fails here rather than further downstream. - The receiver's text is read off the ProseMirror doc: innerText interleaves the remote caret's label with the paragraph. Measured at 495a9fc and 4f02739: 8/8 before the merge across four runs, 2 failed / 6 passed after it across three; 8/8 with this change across three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 upstream commits, 9 conflicts. One lands on the write path this branch rewrites: 87db543 (PRD-7872) refuses a whole-document replace when another writer just touched the document. The other three are a turbo.json gate, a version reset, and ACP model discovery. server-observers.ts (modify/delete): upstream added an external-editor recency signal there; this branch deleted the file with the markdown bridge. Re-homed onto bridge-quiescence.ts, whose attachQuiescenceTracker is the live per-doc afterTransaction hook and already owns the Date.now() calls. Upstream set the stamp inside the fragment and ytext observers, which fire only on a real change, so the doc-level hook gates on tx.changed.size > 0 to keep that precision. agent-write-spine-files.test-helper.ts (modify/delete): the previous merge dropped it along with its two bridge censuses. The helper itself is generic -- it walks src for applyAgentMarkdownWrite -- and 87db543 adds two live consumers, so it comes back at upstream's version. agent-sessions.ts: kept upstream's CONCURRENT_REPLACE_WINDOW_MS, AgentWriteRecency and assertConcurrentReplaceAllowed; dropped the embedResolver, precomputed-parse and loss-detect parameters this branch removed. suppliedWriterId is therefore parameter 4, not 7, and every call site follows: agent-write-routes.ts, lint-write-routes.ts, api-extension.ts and thread-manager.ts. thread-manager.ts keeps upstream's try/catch turning the refusal into a -32009 RequestError, which is the feature's error surface. Two invisible conflicts, neither marked by git: Upstream's 11 new tests in agent-sessions.test.ts merged clean but still called the 7-parameter shape, so sessionWriterId(...) landed in a dropped slot, the guard saw no writer id and 6 refusal tests passed nothing through. Rewritten to the 4-parameter signature. agent-write-spine-walk-root-coverage-contract.test.ts asserts at least two spine censuses. Upstream has three; this branch has one, because the loss-detect and pre-drain censuses went with the bridge. Floor lowered to one with a message saying what it guards. Its real assertion, that every census walks the same root, is untouched. doc-edge-blank-runs.test.ts: dropped upstream's new fragment-only merge-seam test, which needs the ProseMirror fragment this branch removed, and kept its rename of the trailing-run test, whose body now retries the agent replace until the guard window clears. Typecheck 12/12, lint clean. Server 10241 passed / 1 failed, core 4534 passed, app 9340 passed, and the resolved integration file 10/10. The single failure, managed-runtime-flow's real-npm admission probe, fails identically at the pre-merge tip: it needs a registry this environment does not reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upstream's #4430 arrived with the 79e2f86 merge and seeds its rich-text case through `client.fragment`, which this branch deletes; the case died on `Cannot read properties of undefined (reading 'push')`. It is the first full suite run since that merge. Seed it the way the other ported suites do, through the harness's `appendProjectionParagraph`, which writes the paragraph into `Y.Text` via the same block splice the WYSIWYG uses. The distinction the upstream case draws between a source and a rich-text edit is what this branch collapses: both are `Y.Text` writes under the author's own origin, and the gate reads `getLastExternalEditorChangeMs` either way. Evidence: the file runs 11/11; app integration re-run clean is 1,745 / 4, and none of the four is this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two PRD-8464 guards type into the document and then have an agent replace the whole of it. Upstream's #4430 refuses a whole-document replace within `CONCURRENT_REPLACE_WINDOW_MS` (2 s) of an editor change, so both died on `agent-write-md failed: 409`. Neither case exists upstream, and the upstream file passes 2/2 on a clean 9ee18ed worktree, so this is the guards meeting upstream's new contract, not a behaviour difference: upstream's own concurrent-replace test asserts a recent rich-text edit is protected too. Retry the replace until the window passes, the idiom `jsx-unregistered-ime-concurrent.e2e.ts` already uses, so the guards keep testing undo after an agent rewrite rather than the refusal gate. Evidence: the file runs 6/6; the full e2e re-run is 802 / 3, and neither guard is among the three (each of those passes in isolation). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tream
The 30 were written phase by phase, and read as one release they contradicted
each other and the code: two claimed `bridge.lossDetector` still controlled
live behaviour while a third deprecated it, one said remote carets "remain
absent" and another said they were back, and several described "the previous
release" meaning an earlier commit on this branch.
Worse, most described round trips. Each claim was checked against
`upstream/main` rather than against the branch's own history, and these turned
out to be defects this branch introduced and then fixed, so upstream users
never saw them: same-paragraph duplication (spec §9.2), same-mode remote carets
(§9.3), caret and selection survival across a peer edit (`ddc9f3db3`: "Upstream's
fragment held the space"), the agent-rewrite caret (`e7df69ddb`: on main ySync
maps it), the source-mode paste stall (`636c8e19a`: 1,140 ms here against
16.8 ms at the upstream parent `d4218be0`), the conversion-undo steps
(`f196137c5`: the test passes at `d4218be0` with byte-identical files), the
View-in-source button and the mode-switch landing (`515eca737`: "behaves as it
does on main"; upstream's getYDoc finds the `collaboration` extension this
branch deleted), the scoped MDX fallback (`daa030b90`: upstream's
parseWithFallback already scoped by region), and the blank-line and
trailing-space families, whose mechanisms are branch-only.
What is left is nine entries, each a real difference from `v0.76.0` and each
under the 50-word changelog budget AGENTS.md sets: one source of truth, one
undo history, undo outside the editors, undo surviving writes made while away,
the bridge removal, version-history batching, large-document responsiveness,
the first-sync retry, and the dropped-upgrade log.
Four claims are deliberately absent because the record does not say whether
main shares the defect -- the slash-command undo trace, the Mermaid diagram's
undo, the trailing-blank write floor, and the lint-marker narrowing. They are
MP-23 to MP-26 in the PR description's manual checklist, to be run against main
before any of them earns a changeset. The restart replay instrumentation is
absent too: it fixes nothing a user can see, and the defect it diagnosed is
ISSUES.md Issue 4, deferred to its own PR.
`markdown-bridge-removed` describes the four dead `bridge:` switches as removed.
That removal is the next commit; the schema keeps them as accepted-but-unread
today, on a justification that does not hold -- the block is a `z.looseObject`
and the published schema sets `additionalProperties: {}`, so deleting them
breaks no existing config.
check-no-major-changeset.sh clean; scripts/aggregate-stable-changelog and
build-slack-release-payload 34/34.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`1b3a86bc2` moved the only caller to `fullProjection` when it narrowed re-projection to the blocks a peer's edit changes, leaving `liveProjection` exported and unreferenced — the one knip finding on this branch that upstream did not also report. The two STOP comments that named it are updated rather than left stale: the agent-flash hop in TiptapEditor names `fullProjection`, which is what `flashEntry` actually reads, and the hidden-editor note names the binding's projection. Evidence: knip goes 53 → 52 unused exports with no branch-only finding left (upstream `9ee18edab` reports 56); typecheck and lint pass; app unit is 9,343 / 0 with 6 expected fail and app DOM 6,361 / 0, both unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…em as dead config
`bridge.deferGuard`, `bridge.fixedPoint`, `bridge.preDrain` and
`bridge.lossDetector` switched machinery this branch deleted. They were left in
the schema described as "Deprecated and no longer read. Still accepted so
existing .ok/config.yml files keep validating" -- and that justification does
not hold. `bridge` is a `z.looseObject` and the published JSON schema sets
`additionalProperties: {}`, so an unknown key was always accepted and ignored;
`committed-scope-diagnostics.test.ts` asserts exactly that. Nothing was being
protected by keeping them.
What keeping them did cost: all four stayed registered in `fieldRegistry`, so
they kept appearing in the settings surface and in `ok config` as four
plausible switches for anyone debugging a sync problem, none of which could do
anything.
This repo already has the retirement path -- `REMOVED_KEYS`, which raises a
`REMOVED_KEY` diagnostic naming the key and pointing at `ok config migrate`.
That is how `content.include`, `folders`, `appearance.editorModeDefault` and
`server.host` went. The four now go the same way, each with a redirect saying
what the switch used to gate and what replaced it:
- deferGuard: Y.Text is the only synced replica, so there is no second replica
to defer a re-derive against.
- fixedPoint: each client derives its document locally, so there is no
re-derive loop to bound.
- preDrain: a keystroke already lands in the only synced replica.
- lossDetector: persistence still checks every reconciliation for dropped
content and writes a recovery checkpoint; `lossCapture.enabled` still
controls the `ok diagnose` ring.
`bridge.backgroundThrottle` and `bridge.flushOnHide` are untouched and still
live.
Tests follow the state rather than pinning the old one: the two sweeps now
assert the deprecated-leaf set is empty and that a config still setting the
keys parses, the eight per-switch schema tests collapse into one that proves
both, and `kill-switch-sweep`'s fail-closed floor drops from 7 declared leaves
to 3, which is what the enumerator now finds (backgroundThrottle, flushOnHide,
lossCapture).
The published artifacts under `packages/cli/dist/` are gitignored build output;
`packages/cli/scripts/build-config-schema.mjs` regenerates them and
`published-schema.test.ts` runs it -- verified that `bridge` there now carries
only the two live switches.
core config 583/583, cli config 103/103, the three affected app integration
files 23/23, typecheck 12/12, biome and oxlint clean apart from upstream's own
`acp/launch.test.ts` useTemplate finding (`4ff5fb440`). `server-factory` passed
twice and failed one case once across three runs of this same code; it is in
the rotating flake set recorded in ISSUES.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssume `createServer() — generated index wiring > a live index conflict blocks regeneration until the conflict is resolved` ran under vitest's default 5s testTimeout while its own body allows four sequential `vi.waitFor` blocks of 20s each. Alone it needs 5.85s -- boot, two index levels, conflict detection, resolution and a third write -- so it timed out; inside a full-file run it sometimes came in under 5s on warm state and passed. Measured across four runs of the same tree: two file-level passes, two failures, and 3 of 3 failures when run alone with `-t`. Now carries `}, 30_000)`, which is what 17 other tests in this file already do and the coherent envelope for a 20s internal wait. Nothing else changed: no setup was added, because the test is self-sufficient -- with `--testTimeout=60000` and no other edit it passes alone. **Pre-existing and independent of this branch.** Verified by restoring `102fe795b^`'s six config files into the worktree and running the same isolated invocation: still timed out, at 170 tests rather than 174, the count difference confirming the revert took effect. So it is neither the single-CRDT cutover's nor the bridge-switch retirement's. It is also more specific than the rotating load flake set in ISSUES.md, since it reproduces deterministically in isolation; this commit can be lifted out of the branch on its own if the maintainer would rather take it as its own PR. Isolated 3/3 pass; the whole file 174/174 in 188s. biome and oxlint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ok config migrate` wrote back `bridge: {}` after stripping the four retired
bridge switches. buildClearPatch sets each leaf to null and applyPatchToDocument
deletes it, but nothing removed the mapping the last delete emptied, so a
hand-written config kept a dead stanza. It validated clean, which is why MP-09
passed without noticing.
Deleting a key now walks up from its parent, removing each ancestor the delete
left empty and stopping at the first one that still holds something. An explicit
`{}` in a patch was already a no-op -- Object.entries on it yields nothing -- so
no caller loses a deliberate empty map.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manual pass reported a peer's spaces vanishing during same-paragraph co-editing, which would be byte loss in the path this branch is built on. It is not: an unanchored space at the end of a line is dropped when the caret moves away, and from the other client that reads as the peer's space disappearing. peer-same-line-coedit.e2e.ts could not have caught either way -- it types only A and B, never a space. Three cases pin the distinction: space-separated words typed simultaneously at the paragraph end keep every interior space, so do simultaneous space runs anchored by a marker, and unanchored trailing spaces are dropped on caret move with both clients converged. Uppercase markers because the seed text supplies lowercase a and b: counting a over the whole document scores "Target" and every "block". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The leaves-the-workspace arrow is a `::after` on whatever carries `data-resolution-state="external"`, and for a link that carrier is the resolution decoration, which renders as one span per fragment of the decorated range. The suppression rule assumed those fragments are immediately adjacent siblings, so `:has(+ …)` could keep the cue on the final one alone. A widget decoration inside the range breaks that adjacency. This branch draws peer carets as widgets, so a collaborator standing inside a URL renders as fragment, caret widget, fragment: neither fragment has an immediately-following external sibling, both draw the arrow, and the URL reads `http://www. ↗google.com ↗`. Each further caret in the range adds another arrow. Nothing is wrong with the bytes, which is why a switch to Markdown source and back cleared it. Fragments of one link are not siblings of the next link's fragments: the link mark renders its own `[data-link]` element around each link, so the cue can hang off that element and be drawn once however the inside is fragmented. Widening `+` to `~` would not have been safe — wiki-link chips are standalone carriers and two external chips in one paragraph are siblings. Dropping the old rule also fixes those chips, which it suppressed by accident: two adjacent external chips lost the first one's arrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the trailing space The nine the fold left were written against AGENTS.md's 50-word changelog budget. The four added after it were not: 71, 64, 52 and 52 words, and the two worst spent them on mechanism -- the arrow's `:has(+ ...)` suppression rule, why a husk validates -- which the same rule asks to leave in docs. same-line-space-coverage goes entirely. Its commit adds one e2e probe and no product code, and AGENTS.md skips changesets for test-only edits. What it described was coverage, not a change a reader of the release notes could act on. The behaviour that probe pins had no entry at all, which is the real gap. An unanchored trailing space is dropped when the caret moves away, and main's fragment held it, so MP-15 is a difference a user meets on upgrade -- trailing-space-saves-with-the-next-word says so. slash-command-undo-clears-its-query stays. The fold withheld it because the record did not say whether main shared the defect; run against main, main leaves the literal `/he` in the paragraph, so it is a real difference and not a branch round trip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…views Two user-facing surfaces the cutover moved that the docs still described as main behaves. The attachment row promised a size the bare `![[report.pdf]]` form no longer carries: the client resolves the embed's path but not the size the server used to add. Rather than drop the promise, the line now says where sizes do appear, which is the document list. Real-time collaboration described presence as a header affordance. A collaborator's caret is now drawn in the text, labelled, and visible across Markdown source and the visual editor in both directions -- a capability that did not exist before, so no reader would have gone looking for it. No changeset: docs-only, and the behaviour behind each line is already carried by single-source-of-truth and by the PR's own follow-up on embed sizes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ue to its carrier Two defects this branch introduced, both found by running the full e2e suite rather than the specs the cue work added. `.dark [data-presence-badge="agent"][data-presence-mode="editing"]` had become `.dark .dark .dark [...]`. 3cb8b0e deleted the two `.dark [data-agent-flash-state=...]` rules above it and took the bodies but not the selector tokens, so the surviving rule absorbed a leading `.dark` from each. Space-separated, those are descendant combinators: the rule wanted three nested `.dark` ancestors, which never occur, so an agent presence badge in dark mode kept the light-mode breathing animation. A specificity hack would have been written `.dark.dark.dark`; nothing here wanted one. Nothing pinned it -- `agent-breathing-dark` appears nowhere outside the stylesheet. find-replace.e2e.ts is upstream's, added by 3351618 to pin upstream's own fix for the duplicated cue. It reads `::after` off the `[data-resolution-state]` decoration fragments, which is where the cue lived until 3b9c484 moved it to the `[data-link]` element. The test failed 3 of 3 isolated runs and would have shipped red. It now resolves each fragment's carrier as `closest('[data-link]') ?? fragment` and counts carriers that render a cue, so a link mark's fragments share one carrier and a standalone wiki-link chip stays its own -- the distinction that ruled out widening `+` to `~`. The "last fragment carries it" assertion goes: with the cue on the link element there is no last fragment to read, and `cueCount === 1` is the property the test was protecting. No changeset. Both are round trips against this branch's own work, and AGENTS.md keeps round trips out of the changelog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jorgens
requested a deployment
to
inkeep-oss-sync
September 18, 2026 14:33 — with
GitHub Actions
Waiting
|
Thanks for the contribution! What happens next:
|
Contributor
|
Hey @jorgens, thanks for the pr I will take a look. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and Why
Today every document is kept as two live CRDT replicas inside one
Y.Doc:Y.Text('source'), the markdown that reaches disk, andY.XmlFragment('default'), the ProseMirror tree the WYSIWYG binds. A server-side bridge keeps them equal after the fact: Observer A serializes the whole fragment and line-diffs it intoY.Texton every WYSIWYG keystroke; Observer B parses the wholeY.Textand rebuilds the fragment on every source keystroke.This PR makes
Y.Textthe only CRDT. Each client parses it into a local, unsynced ProseMirror document (the projection). A WYSIWYG edit re-serializes only the top-level blocks it changed, narrows the write to the bytes that differ, and splices them intoY.Textunder the user's own origin. Source mode binds the sameY.Textas before. The fragment, both observers and every guard around them are deleted. The fragment path is gone; the only code still reading the old binding is upstream's list drag (see What it costs).Note to the maintainers
This draft pull request has kept growing. The change of the data model, which it addresses, ran deeper than initially expected, and releasing the impacted pieces only makes sense, and is only safe, if it is done in one go. So unfortunately, the pull request is huge. I hope you still will take time to consider it.
What prompted the exploration was the reported issue #1475 and a not easily reproducible issue where the WYSIWYG and the source mode would differ and not agree until after a complete restart of the app.
The goal of the change:
Along the way further undo-issues were uncovered and a race with your rapid release cycles started. Kudos to you for all the work you are putting into this great project!
If the PR has become too big for you to look into in more detail, this would be totally understandable, nevertheless I really believe that the architectural change would be worth it.
If for nothing else, then this draft might be useful as a proof-of-concept.
As you can see from the commits, the changes were largely done using Claude Opus, but I can assure you that the Human was in the loop, a lot of manual testing, pointing and redirection has gone into it.
Checklist
pnpm check(lint, typecheck, tests) locallypnpm changeset) if this changes behaviorWhy one CRDT
Two replicas that converge independently do not converge with each other. Keeping them equal is the dual-write problem, and almost every cost below comes from that one fact. A single CRDT would solve the problem.
Y.Text, never mergedY.UndoManager, one LIFO across both viewsY.Textunder the server's originConvergence becomes a property of the data type. With one CRDT, convergence is what Yjs already guarantees for a single
Y.Text. The ProseMirror document is derived from it and never synced, so there is no second state to drift, and the whole "WYSIWYG shows something other than the markdown" class of bugs cannot happen, rather than being guarded against. (Tabs still running the old client across the upgrade are the exception; see Follow-ups.)One write path for every writer. Source mode, WYSIWYG, agent writes, the file watcher and rollback all write
Y.Text, and nothing runs afterwards. Agents already only ever wrote markdown; the bridge existed to serve a single arrow, WYSIWYG → fragment →Y.Text. The client now performs that same translation itself.One undo history. With one shared type there is one
Y.UndoManagerper document, and both views write through it under tracked origins. On the base, undo only retracted edits made in the view you were undoing from, and the bridge's rewrites ran under an origin neither stack tracked, so a bridge rewrite could split a user's undo frame. Undo is one LIFO: the most recent edit retracts, whichever view made it, and a source frame survives an interleaved WYSIWYG edit and retracts whole. The same attribution is what lets buffered offline edits replay under the right author.The bytes on disk change only where you edited. A WYSIWYG edit now writes only the blocks it changed, narrowed to the bytes that differ. In a one-off run over the docs corpus plus hand-built hazards (loose lists, reference definitions, footnotes, fences, tables, blank runs), 1,752 of 1,752 block splices touched only the edited block;
block-splice.test.tskeeps that containment property pinned on a large realistic fixture.Typing is cheap at any document size. Observer B paid a full parse per source keystroke. Now neither a source nor a WYSIWYG keystroke makes the server parse or serialize markdown, and each client's own work is proportional to the change: up to 128 KiB, typing and a peer's typing both measure what they do on
main. Agent writes still parse on the server, and a book-length document still stalls (see Follow-ups).Less to sync and cache. The fragment was 2.4–6.6× the size of the
Y.Text, i.e. 70–87% of every sync payload and of what IndexedDB caches. Fragments are now gone.Carets work across modes. With one CRDT there is one coordinate space. Remote carets are anchored to
Y.Textoffsets and rendered in whichever mode each client is in, so a collaborator is visible across source and WYSIWYG.A smaller system, on a well-trodden model. Fewer lines of production code and fewer lines of tests. The bridge's reach shows in the server's test surface: 43 of its test files are deleted here and 32 more rewritten.
Trade offs
test.failsinlist-item-drag.collab.test.ts; one flipped once a peer's edit stopped replacing the whole document. By hand the drag completes and loses nothing, so the divergence is in the collab merge model rather than in what the drag does on screen.packages/app/src/globals.csswas byte-identical to upstream; it no longer is, in three places. The leaves-the-workspace cue (↗) hung off each fragment of a link's resolution decoration and was suppressed by an adjacent-sibling rule; this branch draws peer carets as widget decorations, and one standing inside a URL splits the run, so every fragment drew its own arrow — the cue now hangs off the link element instead. The agent-flash block-guessing wash is deleted, replaced by the position-accurate inline decoration that flashes the range an agent actually wrote. And remote-caret host blocks opt out ofcontent-visibility, which would otherwise clip a peer's name label. No upstream TypeScript is touched, but it is ~130 lines of divergence to carry through future merges.packages/app/tests/stress/find-replace.e2e.tsfollows the cue to its new carrier, andpackages/app/package.jsondiverges in itstest:e2elist: bridge-era specs out, the projection's own in.Size of the change
Diffed against the upstream tip this branch last merged (
f9a52ebcb). Tests dominate the line count, so they are split out:Production code by package:
serverappcoredesktopcliMost of the test deletion is the bridge-era surface: the concurrency matrix, the convergence fuzzer, and the suites and rigs that seeded or asserted the fragment. Most of the test addition is the projection's own coverage (the binding, the write path's containment oracle, cross-mode undo across a real CodeMirror and a real ProseMirror view) and the upstream tests ported onto projection-bound peers.
Why this is one PR
The short version: the cutover is a change of data model, and the pieces only make sense, and are only safe, together.
The server and client halves cannot ship in different releases. The switch of every client to the projection and the removal of the bridge have to arrive together.
The flag-gated intermediate state was built, and it is the worst of both. The branch went through it. In that state
maincarries two editor constructions and the full bridge, the server keeps parsing on every write to feed a fragment only unflagged clients read, every test has to say which arm it pins, and none of the advantages above arrive.Most of the follow-up commits fix regressions the cutover itself introduced. Moving to text-level sync broke things the tree CRDT gave for free and the docs advertise under "Real-time collaboration".
The deletions cannot trail the cutover. With the bridge no longer running, its tests either fail by construction or keep passing while asserting nothing.
What has been kept out
Seven pre-existing defects surfaced along the way.
refindtrusts its stored offsets with no context evidence.editreplaces one occurrence and reports unqualified success.template-watcher-capabilitiesfailure.ghCLI outlives every run, and its orphans wreck later suites. This is the machine-state caveat under Automated suites.Two of those attributions are not settled. Issue 1 no longer reproduces on upstream, and Issue 4's "pre-existing" reading rests on a byte-identical comparison.
The suite's reds are kept out too. See automated suites section for more information.
Check the proposed follow ups, further down.
Suggested Review Path
The branch is 95 commits (plus eight merges of upstream), and each one carries its reasoning in the message, because the no-comments policy keeps that prose out of the code.
core/markdown/pm-source-map.ts,core/markdown/source-blocks.ts,core/projection/block-splice.ts. These are pure functions over ProseMirror's document model — no editor instance, no view, no DOM — and tested against a containment oracle.app/editor/projection-binding.ts,projection-coordinates.ts,shared-undo-manager.ts.fbcbacf31), which is small: +163 / −50 lines of production code, the rest tests.99676c280server,69ab4159fclient,4f6bd8e9dtests). Only the server one is mostly whole files — 46 deleted against 14 edited — and there the question for each is just "is anything still reachable". The other two are more edit than removal (client: 6 deleted, 11 edited; tests: 54 deleted, 61 edited), so they want reading rather than skimming. All three commit messages list what was deliberately not deleted, and why.353c8a5a4(the loss detector handed to disk intake) and5bcd56498(the projection's trailing-empty floor).How this was tested
Manual testing
Most defects the migration found came from using the app, not from the test suite. Fixes that changed behaviour ended with a manual pass. In the end the HIL is the best judge of what behaviour comes natural.
The manual checklist
[**Desktop**](x); type into a paragraph in the middle; check the file[**Desktop**](x)must not become**[Desktop](x)**main, are not defects to file: Enter on an ordered item writes1.not2., and Enter on a task item writes- [ ]  . Re-checked by code identity, not by a side-by-side run:to-markdown-handlers.ts(the ordinal logic) andempty-task-item.ts(the minting) are both zero-diff againstupstream/main, as arelist-editing-helpers.tsandlist-item-drag.ts— the only list file this branch touches islist-item-drag.collab.test.ts. Both behaviours are decided inside that identical serializer from the node's ownstartandcheckedfields. Accepted as good enoughmainin either directionmainhad this throughyCursorPlugin; the cutover dropped it and it was rebuilt, so it is a no-regression row, not a featuremainthe browser's undo answers here, which can skip an edit or re-insert a deleted one. The Windows and Linux menubar path is untested in any formmain, returning to source after any untracked write cleared itbridge.deferGuard,bridge.fixedPoint,bridge.preDrainandbridge.lossDetectorin.ok/config.yml, start the server, then read the metrics payloadREMOVED_KEYdiagnostic naming what replaced it, and is stripped from the parsed config. The config is recovered rather than rejected: the server starts, and every other setting in the file still applies. The eight bridge counters are present and at rest — seven zeroes and an emptybridgeToleranceAppliedmap.ok config migratestrips the four; no other upgrade step is requiredcurl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" http://127.0.0.1:<PORT>/not-a-collab-path— against a real server (ok startor the desktop), not the vite dev server, whose collaboration path never installs this handlermainmerged this structurally; this PR merges the bytes. One machine cannot do this by hand —peer-same-line-coedit.e2e.tsis the authority. A peer's spaces appearing to vanish here is MP-15, not a merge fault: an unanchored space at the paragraph end is dropped when that peer's caret moves away, which from the other side looks like concurrent-edit loss. Interior spaces survive — measured with both peers typing space-separated words and simultaneous space runs at the paragraph endmain, whose fragment held it. It is kept while the caret sits after it, and saved with the next word you type —trailing-space-saves-with-the-next-word[text](url), or a bare URL followed by a space, then Cmd+Z oncemain, which left the literal[text](url)behindlist-item-drag.collab.test.tsstaytest.fails: the divergence is in the collab merge model, not in what the drag does on screen.![[report.pdf]]![[…]]form. Its size is no longer shown — the client resolves the path but not the size the server used to add</Callout>)main. A one-line<Callout>text</Callout>does not render as a component at all; only the multi-line block form does. That is identical on upstream./he, pick Heading from the menu, then Cmd+Z past the heading/hedoes not come back.maincrashed the server and needed a restart; the branch stayed up. The branch is still slow at this sizeAutomated suites
The latest full run, one suite at a time, on an otherwise idle machine verified clear of the orphan leak below. Every row but
e2eis from the sweep at7133f19c7(upstreamf9a52ebcb);e2eis from a later run at8776c5d0b. Nothing between those two tips touches the suites that ran earlier, and the threeglobals.cssunit suites were re-run at the later tip and stay green.test:e2e, 10.3 min)test.failscases for upstream's list drag, inlist-item-drag.collab.test.ts. When a peer edit shifts positions mid-drag, there's no live binding to map that anchor. These are app unit cases, not e2e. They flip when the drag is fixed.managed-runtime-flow's "login-shell runtime performs real npm admission in its isolated cwd", which needs a registry this environment does not reach. The file is zero-diff from upstream. On a machine clear of the orphan leak below, the whole server suite runs in 6.9 minutes and produces this single red without any isolated re-run.no-commentspair fails identically on upstream — test and fixture are both zero-diff, and the public mirror's fixture yields 30 diagnostics where the test wants 31.project-sweep-capacityis the documented upstream race.packages/md-conformanceand one wantsplugins/ok, neither of which the public mirror ships. The branch does touchcheck-server-test-inputs.test.mjs, but on a different row — it retired the../cli/tsdown.config.tscase the merge had made dead.show-ok-foldersandwarm-skeleton-scroll-restorefail only under full-suite contention — 2 of 2 and 3 of 3 alone — and both files are zero-diff from upstream. Two others flaked in the earlier sweep and passed alone as well,peer-same-line-coedit3 of 3 andkeystroke-cadence-danger-space3 of 3 after one failure; both passed in the re-run.nodeprocesses sat on the machine — one left behind by every past run oflocal-op-auth-login-cancel-reopen.test.ts, whose fakeghfixture refuses SIGTERM by design and holds itself open with a ~12.4-day timer. With them cleared, the same 15 files went from 3,379 s and 15 reds to 208 s and 1. One such orphan was found and killed before this run. Filed as Issue 8; it is a test-fixture leak, not product code, and it predates this branch.Follow-ups
Each to be filed as its own issue.
heading-anchorsrebuilds a decoration per heading on every state (23%), and ProseMirror diffs whole-document decoration sets. The fix is the one the block wrappers already use — hold the set as plugin state, map it through each transaction, rewrap only what changed. This is the first follow-up.snapshotBlocks, which finds the blocks an agent write changed, used to stringify the synced tree's top-level children. It now parses the markdown, once before the write and once after, on the main thread inside the transaction. Each call takes 533 ms at 488 KiB (6,177 blocks), 726 ms at 618 KiB and 0.3 ms on a small document, so about 1.1 s per agent write at 488 KiB.5d14a3a0f) still anchors through the old binding. Six of ten ported cases diverge in the collab merge model and are markedtest.failsinlist-item-drag.collab.test.ts— app unit cases, not e2e. By hand the drag completes and loses nothing, so the earlier "any peer edit cancels the drag" reading is retired; what remains is the model-level divergence those six pin. They are not tests of dragging: each lands a peer edit while the drag is in flight and then asserts that both replicas converge on the same markdown. All six put that edit inside or before the dragged range — a peer typing into the item being dragged, prepending an item so every ordinal shifts, inserting a paragraph above the list — which is exactly the positionabsolutePositionToRelativePositionused to map the drag's anchor through. The whole-document replace was the suspected cause, and narrowing it in1b3a86bc2flipped three cases: the 2 of 19 SelectionAnnouncer cases, where a peer edit made the screen-reader status re-announce the selected component, and one drag case — the one whose peer edit lands outside the dragged selection, the only one that never needed the anchor remapped.![[…]]no longer show their size. This PR resolves the embed's path on the client, but not the size the server used to add. The document list already carries sizes.caretSourceOffsetToPmPosrefines a position only through spans typedtextand otherwise returns the end of the span, and the span a fenced block's text child inherits includes its ``` fences; three backticks against a three-character offset is suggestive, not established. An indented code block, which has no fence, is the cheap falsification. Filed as F9.bridge-intake.ts,bridge-quiescence.ts(now the persistence settle gate),bridge-loss-detector.ts(still live in persistence), thecore/src/bridge/utilities, thebridge.*span names,normalizeBridge,paired: true(its readerisPairedWriteOriginis still exported but has no production caller), the Observer A and map-driven splice counters still declared in the metrics payload, and the three unused parametersapplyDiskContentToDocstill declares —_resolveEmbed,_sourcePathand_resolveSize— which no call site passes.project-sweep-capacityintermittently. During a project-wide Fix all that churns agent sessions, the stale-external-write gate reads one of the server's own saves as an external restore of a displaced version and raises a conflict, so a concurrent agent write gets 409. Run alone, the test failed 2 of 3 times on upstream92f35f5d9, 2 of 5 on985247f5a, and 7 of 7 on this branch ate97b44ef5. At4f0273995it passed in the full integration run, so the branch does not make it deterministic after all. Atd72bb13aait failed in the full run and passed alone, ataf3c4c89fthe same, and again in the latest sweep: red under the full integration run, green alone. The branch loses this race intermittently, as upstream does.