Commit 1a995ff
authored
feat(files): stream copilot edits into the collaborative doc smoothly (#6122)
* feat(files): stream copilot edits into the collaborative doc smoothly
- apply the agent stream client-side into the live Yjs binding as minimal
updateYFragment diffs (like main's setContent, but incremental) so it renders
smoothly AND broadcasts to every peer via CRDT — a collaborator on /files sees
the stream for free
- gate the apply on collabReady so diffs never land on an unseeded doc; keep the
read-only placeholder visible until the seed swaps in
- run streamed ops under a dedicated tx origin so they stay out of the user's
undo stack
- delete the throttled server-side streaming merge and the baseVersion ordering
machinery it needed (relay + notify + session contract); the durable final
write still reconciles open editors and seeds late joiners
* fix(files): apply agent stream as a true CRDT peer + guard base-less snapshots
Review round 1 (Greptile P1s):
- apply the stream against a private shadow replica (seeded from the live doc at
stream start) and relay only the agent's own delta into the shared doc, so a
concurrent peer edit to a region the agent snapshot didn't include is no longer
reverted (previously the whole-body reconcile deleted it)
- gate append snapshots on "must extend the base": a base-less append fragment
(emitted before the base loads) can no longer reconcile the seeded doc to a wipe;
patch still legitimately replaces a mid-region
- gate the apply on collabReady so diffs never land on an unseeded doc; keep the
placeholder visible until the seed swaps in
- plumb streamOperation through the preview surfaces to drive the append gate
- add a peer-edit-preservation test (fails under whole-body reconcile) and refresh
the undo-isolation + broadcast tests for the session API
* fix(files): destroy the agent shadow deterministically on settle
Cursor round 1 (Low): endAgentStream ran inside runOffRender, whose microtask is
dropped when a rapid follow-up stream bumps the run token — leaking the shadow
Y.Doc. Split it out into an unguarded microtask queued after the (droppable) final
apply, so the shadow is always destroyed.
* fix(files): agent stream frames skip the relay's durable persist
Cursor round 1 (High): client-applied stream frames broadcast over the sync
channel, so the relay stamped a socket origin and ran schedulePersist — durably
writing partial agent content mid-stream, attributed to the watching user (the old
server-merge applied with no origin and never did). Restore that behavior:
- new FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST wire tag; the provider tags
AGENT_STREAM_ORIGIN updates with it (normal user edits stay SYNC)
- the relay applies it under an AgentSyncOrigin (carries the socket id for
broadcast exclusion, but is not a plain string) so originSocketId() is null →
no edited/schedulePersist/lastEditorUserId; excludeSocketId() still excludes the
sender, and the update still publishes to the stream so peers converge
- the copilot's final edit_content write remains the authoritative durable persist
- tests: relay applies+fans-out but never persists a SYNC_NO_PERSIST frame
(verified it fails if applied as a socket edit); provider tags agent edits
* fix(files): open the stream shadow at start + private extend baseline
Cursor round 2:
- High (settle skips apply without session): the stream shadow is now opened on
the first ready frame, BEFORE the extend gate — so an `update` rewrite (whose
every frame is gated out until settle) and a stream that finishes before seed
still get a session, and settle applies the final body via the reused-or-on-demand
shadow instead of leaving the doc stale until the durable reconcile.
- Medium (peer edits stall the stream): the extend gate now reads a private
`lastStreamedBodyRef` (the agent's own last frame), snapshotted at stream start,
not `lastSyncedBodyRef` which `onUpdate` clobbers on peer edits — so a collaborator
typing can't make the growing snapshot stop prefixing the shown body and freeze it.
- Medium (multi-replica over-persist): pre-existing, documented "safe over-persist"
(a peer task tails the frame as REDIS_ORIGIN and marks edited) — refreshed the
stale comment to describe the SYNC_NO_PERSIST source; copilot's edit_content write
remains the authoritative durable persist.
* fix(files): fail-close base-less previews + operation-based stream hold
Cursor/Greptile round 3 (High + Medium) — remove the fragile string-prefix
"extend gate", which was the root of both findings:
- Server: `buildFilePreviewText` now fails closed for an `append` whose base
content hasn't loaded (returns undefined, like patch/update), so a base-less
fragment never reaches the client. This eliminates the base-less wipe at
settle (Greptile P1) at the source; an empty file (existingContent === '')
still previews normally.
- Client: the collab streaming tick no longer string-prefixes the raw preview
against the editor's canonical markdown (the '*' vs '-' / emphasis mismatch
that froze every append frame — Cursor). The mid-stream hold is now purely
operation-based: `update` waits for settle; append/patch/create apply each
frame via the (peer-safe) shadow reconcile. lastStreamedBodyRef is now a plain
dedup guard, not a prefix baseline.
Keeps the shadow, durable write, and SYNC_NO_PERSIST unchanged.
* fix(files): elect a single agent-stream writer across tabs
Cursor round 4 (High): with the stream applied client-side, two tabs/windows on
the same chat could each derive streamingContent (the reconnect/resume path
re-consumes preview events) and each independently insert the stream under a
different Yjs clientID, duplicating content until the durable reconcile.
Fix — single-writer election via the file-doc awareness (new agent-stream-leader):
- a client applying an agent stream announces `agentApplying` on its own awareness
- only the leader (min clientID among announcers) applies mid-stream AND at settle;
a non-leader renders the leader's ops via Yjs and does not apply (a non-leader
applying the final body would re-insert the whole doc as a duplicate)
- re-checked each frame, so it converges to one writer the moment awareness
propagates; the sub-frame startup race is reconciled by the durable write
- single-client (the common case) is unaffected: it is the only announcer, so it
always leads
* fix(files): gate the settle apply locally, not on a settle-time re-election
Cursor round 5 (High): the settle recomputed leadership from live awareness and
the leader cleared its announcement immediately, so a straggler peer that settled
afterward became the sole announcer, self-elected, and applied finalBody through
its base-seeded shadow — re-inserting the whole doc as a duplicate.
Fix: gate the settle apply on a LOCAL didApplyStreamRef (set only when this client
actually applied a mid-stream frame — i.e. it was the mid-stream leader whose
shadow is up to date), not on a settle-time re-election. A client that never
applied (non-leader, a held `update`, or a pre-seed stream) skips the final apply
and converges via Yjs + the durable write. The mid-stream leader election
(isAgentStreamLeader) is unchanged, so exactly one client's didApplyStreamRef is
ever true.
* fix(files): open the agent-stream shadow lazily on lead (no stale handoff)
Greptile round 6 (P1): the leader race — (a) a mid-stream leadership handoff
could apply from a stale pre-stream shadow, and (b) two tabs starting the same
stream before awareness converges could both lead briefly.
- (a) fixed: the shadow is now opened LAZILY in the tick, only when this client
actually leads, seeded from the CURRENT doc — so a handoff successor diffs
against the prior leader's ops (never a stale base) and a non-leader builds no
shadow at all. Announce candidacy via a dedicated ref (decoupled from the
shadow); settle still gates the final apply on didApplyStreamRef (leader-only).
- (b) the pure startup race is inherent to eventually-consistent election. It is
now the only residual: bounded to two tabs starting the SAME stream within the
awareness-propagation window, transient (converges in a frame or two), and
never persisted (SYNC_NO_PERSIST + the durable edit_content reconcile). Resumes
are sequential, so the common multi-tab case elects cleanly. Documented inline;
a server-granted lease would close it fully but at a round-trip cost on the
common single-tab path, which isn't worth it.
* fix(files): idempotent settle apply (update lands client-side; no straggler dup)
Cursor round 6 (Medium): a lone client's `update` never applied client-side —
held mid-stream, then skipped by the didApplyStreamRef settle gate — so the
rewrite depended entirely on the durable merge (stale if delayed/failed).
Root cause was over-correcting round 5. Now that the shadow is opened lazily in
the tick (current-seeded), the round-5 base-shadow duplication is already gone,
so didApplyStreamRef is unnecessary. Replaced it: settle applies the final body
via `agentStreamSessionRef.current ?? beginAgentStream(editor)` — the leader
reuses its up-to-date shadow (last throttled frame), while a client that never
applied (non-leader, held `update`, pre-seed) opens a FRESH current-seeded shadow.
Reconciling current->final is idempotent: a straggler that settles after another
wrote the final reconciles to a noop. So a lone `update` applies at settle (no
wait on the merge), and there's still no settle-time election or base-shadow dup.
* fix(files): broadcast agent frames to the whole room (same-socket siblings)
Cursor round 7 (Medium): SYNC_NO_PERSIST frames applied under an origin carrying
the sender socket id, and excludeSocketId dropped that whole socket from the
relay fan-out. A second FileDocProvider on the same socket (chat preview + Files
editor) then missed all mid-stream ops and stayed stale until the durable
reconcile — a regression from the old no-origin server merge, which reached both.
Fix: the agent origin is now a plain AGENT_SYNC_ORIGIN symbol, and agent frames
broadcast to the WHOLE room (no socket excluded), matching the old behavior — so a
same-socket sibling provider stays live; the emitting provider no-ops on its own
echo (the ops are already applied locally). originSocketId still returns null for
the symbol, so it keeps skipping edited/schedulePersist. Removed excludeSocketId
and the socket-carrying origin object. Updated the relay test to assert the
whole-room broadcast (verified it fails if the sender is excluded).
* fix(files): tag agent stream frames no-persist across replicas
A peer task tailing an agent-streamed preview frame previously applied it
as REDIS_ORIGIN, marking the seeded room edited and making a transient
startup-race duplicate eligible for that task's last-disconnect flush. Mark
agent frames with a stream field so peers apply them as REDIS_AGENT_ORIGIN,
excluded from the edited/persist gate. The copilot's durable edit_content
write stays the sole authority over file bytes.
* fix(files): reseed agent shadow on lead regain + agent-only compaction
Two multi-writer edge cases surfaced in review:
- rich-markdown-editor: a client that led, lost leadership, then regained it
reused its stale shadow (which never saw the interim leader's ops), re-emitting
ops for content already present. Tear the shadow down when a client observes it
is not the leader, so a regain rebuilds fresh from the current doc.
- file-doc-store: compaction always stamped its snapshot REDIS_SNAPSHOT_ORIGIN
(marks peers edited). A long agent-only stream crossing the threshold could
fold preview content into a persist-eligible snapshot. Track whether a room
integrated any real edit and stamp an agent-only snapshot REDIS_AGENT_ORIGIN
so it stays no-persist.
Both covered by falsification-verified tests.
* fix(files): close realEdited data-loss race + elect a settle writer
Independent audit surfaced two real gaps:
- file-doc-store: realEdited was latched AFTER appendUpdate's awaits, but the
edit already sits in room.doc synchronously. A concurrent agent-frame
compaction could read realEdited=false, snapshot that real content, and stamp
it a no-persist agent frame — a lost edit. Latch it synchronously (same tick
as the doc mutation) before any await. Deterministic falsifiable test added.
- rich-markdown-editor: at settle every tab applied the final body, and a
non-leader's local microtask runs before the leader's final propagates, so
both insert the tail (Yjs keeps both) -> duplicated tail. Elect a single
settle writer (reliable — awareness is long converged by settle), reading
leadership before clearing the announcement. Corrects the overclaiming
idempotency comment and the handoff pick-up comment.
Adds a y-tiptap internals upgrade-guardrail test.
* fix(files): own presence per client id, not one-per-socket
The shared workspace socket hosts one collaborative provider per mounted view,
so the chat file preview and the standalone Files editor for the same file each
bind their own Yjs client id over ONE socket. The relay owned a single client id
per socket, so the later JOIN overwrote the earlier and dropped its awareness —
which silently broke the single-writer agent-stream election (a peer stopped
seeing the streaming provider's announcement and could self-elect, duplicating
streamed text for the whole stream).
Track ownership per (socket, client id): a socket owns a set of client ids; the
awareness gate accepts a frame only if every id it carries is owned; cleanup
drops all of a socket's ids; the roster stays one-entry-per-session. Reclaim and
the same-user reconnect path evict just the reclaimed id, dropping the old socket
only if it empties. Falsification-verified test added.1 parent ae8f662 commit 1a995ff
24 files changed
Lines changed: 1089 additions & 512 deletions
File tree
- apps
- realtime/src
- handlers
- routes
- sim
- app/workspace/[workspaceId]
- files/components/file-viewer
- rich-markdown-editor
- collaboration
- home/components/mothership-view/components/resource-content
- lib
- copilot
- request
- go
- session
- tools/server/files
- realtime
- packages/realtime-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
103 | | - | |
| 103 | + | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
228 | 234 | | |
229 | 235 | | |
230 | 236 | | |
| |||
239 | 245 | | |
240 | 246 | | |
241 | 247 | | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
242 | 326 | | |
243 | 327 | | |
244 | 328 | | |
| |||
382 | 466 | | |
383 | 467 | | |
384 | 468 | | |
385 | | - | |
386 | | - | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
387 | 483 | | |
388 | 484 | | |
389 | 485 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
| 36 | + | |
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
93 | 103 | | |
94 | 104 | | |
95 | 105 | | |
| |||
103 | 113 | | |
104 | 114 | | |
105 | 115 | | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
106 | 119 | | |
107 | 120 | | |
108 | 121 | | |
| |||
167 | 180 | | |
168 | 181 | | |
169 | 182 | | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
170 | 189 | | |
171 | 190 | | |
172 | 191 | | |
173 | 192 | | |
174 | 193 | | |
175 | 194 | | |
176 | 195 | | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
177 | 204 | | |
178 | 205 | | |
179 | 206 | | |
| |||
237 | 264 | | |
238 | 265 | | |
239 | 266 | | |
240 | | - | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
241 | 274 | | |
242 | 275 | | |
243 | 276 | | |
| |||
264 | 297 | | |
265 | 298 | | |
266 | 299 | | |
267 | | - | |
| 300 | + | |
268 | 301 | | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
269 | 313 | | |
| 314 | + | |
| 315 | + | |
270 | 316 | | |
271 | 317 | | |
272 | | - | |
| 318 | + | |
273 | 319 | | |
274 | 320 | | |
275 | 321 | | |
| |||
288 | 334 | | |
289 | 335 | | |
290 | 336 | | |
291 | | - | |
| 337 | + | |
| 338 | + | |
292 | 339 | | |
293 | | - | |
| 340 | + | |
294 | 341 | | |
295 | | - | |
| 342 | + | |
296 | 343 | | |
297 | 344 | | |
298 | 345 | | |
| |||
515 | 562 | | |
516 | 563 | | |
517 | 564 | | |
518 | | - | |
519 | | - | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
520 | 573 | | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
521 | 582 | | |
522 | 583 | | |
523 | 584 | | |
| |||
578 | 639 | | |
579 | 640 | | |
580 | 641 | | |
581 | | - | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
582 | 647 | | |
583 | 648 | | |
584 | | - | |
| 649 | + | |
585 | 650 | | |
586 | 651 | | |
587 | 652 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | | - | |
| 63 | + | |
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
76 | 76 | | |
77 | 77 | | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
| 78 | + | |
83 | 79 | | |
84 | 80 | | |
85 | 81 | | |
86 | 82 | | |
87 | | - | |
| 83 | + | |
88 | 84 | | |
89 | 85 | | |
90 | 86 | | |
0 commit comments