Skip to content

Commit db571c4

Browse files
committed
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.
1 parent 6b23e8a commit db571c4

2 files changed

Lines changed: 41 additions & 24 deletions

File tree

apps/realtime/src/handlers/file-doc.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -719,10 +719,12 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
719719
// snapshot on catch-up (REDIS_SNAPSHOT_ORIGIN) also counts: it folds real edits into one frame, so a
720720
// fresh task catching up purely from it must not treat the doc as unedited. The seed transition
721721
// itself is never counted, so a seeded-but-unedited doc is never projected back over the file. NOTE:
722-
// in the multi-replica path a copilot merge is NOT excluded — it round-trips through the stream as
723-
// REDIS_ORIGIN, indistinguishable from a peer edit, so it marks `edited`. That only ever causes an
724-
// extra idempotent persist of content copilot already wrote directly (safe over-persist, never a lost
725-
// edit); the single-replica path applies the merge locally with no origin and does not count it.
722+
// an agent-streamed frame ({@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}) is not counted on the
723+
// ORIGINATING task (it applies under an AgentSyncOrigin — see the handler), but in the multi-replica
724+
// path it is published to the stream and PEER tasks apply it as REDIS_ORIGIN, indistinguishable from a
725+
// peer edit, so it marks `edited` there. That only ever causes an extra idempotent persist of content
726+
// the copilot's final `edit_content` write persists durably anyway (safe over-persist, never a lost
727+
// edit); fully suppressing it would require tagging the stream entry as no-persist across replicas.
726728
const seededBefore = room.seededObserved
727729
if (isDocSeeded(room.doc)) room.seededObserved = true
728730
if (

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,14 @@ export function LoadedRichMarkdownEditor({
338338
const lastSyncedBodyRef = useRef<string | null>(
339339
streamingAtMountRef.current ? null : splitFrontmatter(content).body
340340
)
341+
/**
342+
* The body the AGENT last streamed into the collaborative doc — the extend-gate baseline for the collab
343+
* streaming path. Written ONLY at stream start (snapshotting the pre-stream base) and by the streaming
344+
* tick, never by `onUpdate`, so a concurrent PEER edit (which does clobber {@link lastSyncedBodyRef} via
345+
* `onUpdate`) can't make the agent's growing snapshot stop prefixing the shown body and stall the stream.
346+
* Reset to `null` on settle so the next stream re-captures its own baseline.
347+
*/
348+
const lastStreamedBodyRef = useRef<string | null>(null)
341349
const onChangeRef = useRef(onChange)
342350
onChangeRef.current = onChange
343351
const onSaveShortcutRef = useRef(onSaveShortcut)
@@ -864,22 +872,32 @@ export function LoadedRichMarkdownEditor({
864872
// re-runs and applies once it lands (the read-only placeholder shows the base content meanwhile —
865873
// see `showPlaceholder`).
866874
if (!collabReady) return
875+
// Open the stream's shadow on the FIRST ready frame — BEFORE the extend gate — so its shadow
876+
// captures the pre-stream base (immune to later peer edits) even for an `update` whose every frame
877+
// is gated out until settle. Snapshot that base as the agent's private extend-gate baseline.
878+
if (agentStreamSessionRef.current === null) {
879+
agentStreamSessionRef.current = beginAgentStream(editor)
880+
lastStreamedBodyRef.current = lastSyncedBodyRef.current
881+
}
882+
const session = agentStreamSessionRef.current
867883
const body = splitFrontmatter(content).body
868-
if (body === lastSyncedBodyRef.current) return
884+
if (body === lastStreamedBodyRef.current) return
869885
pendingStreamBodyRef.current = body
870886
if (streamRafRef.current !== null) return
871887
const tick = () => {
872888
const pending = pendingStreamBodyRef.current
873-
if (pending === null || pending === lastSyncedBodyRef.current) {
889+
if (pending === null || pending === lastStreamedBodyRef.current) {
874890
streamRafRef.current = null
875891
return
876892
}
877-
const shownBody = lastSyncedBodyRef.current
893+
const shownBody = lastStreamedBodyRef.current
878894
const extendsShown = shownBody === null || pending.startsWith(shownBody)
879-
// Every snapshot except a mid-document `patch` must EXTEND what's shown: a from-scratch rebuild
880-
// (`create`/`update`) is only revealed as it grows, and an `append` snapshot that doesn't extend
881-
// the base is a base-less fragment (the server emits one before the base loads) which would
882-
// reconcile the seeded doc down to a wipe. Only `patch` legitimately replaces a mid-region.
895+
// Every snapshot except a mid-document `patch` must EXTEND what the AGENT last streamed: a
896+
// from-scratch rebuild (`create`/`update`) is only revealed as it grows, and an `append`
897+
// snapshot that doesn't extend the base is a base-less fragment (the server emits one before the
898+
// base loads) which would reconcile the seeded doc down to a wipe. Only `patch` replaces a
899+
// mid-region. The baseline is the agent's own last frame (not the editor body), so a concurrent
900+
// peer edit can't make the growing snapshot stop prefixing it and stall the stream.
883901
if (!extendsShown && streamOperationRef.current !== 'patch') {
884902
streamRafRef.current = null
885903
return
@@ -893,16 +911,14 @@ export function LoadedRichMarkdownEditor({
893911
}
894912
const el = containerRef.current
895913
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
896-
agentStreamSessionRef.current ??= beginAgentStream(editor)
897-
const session = agentStreamSessionRef.current
898914
// Defensive: a ready collab editor always has a ySync binding, so this applies; if one is
899915
// somehow absent, bail this frame without advancing rather than looping.
900916
if (!session || !applyAgentStreamFrame(editor, session, pending)) {
901917
streamRafRef.current = null
902918
return
903919
}
904920
streamRafRef.current = null
905-
lastSyncedBodyRef.current = pending
921+
lastStreamedBodyRef.current = pending
906922
lastStreamParseAtRef.current = performance.now()
907923
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
908924
}
@@ -913,22 +929,20 @@ export function LoadedRichMarkdownEditor({
913929
cancelAnimationFrame(streamRafRef.current)
914930
streamRafRef.current = null
915931
}
916-
// Settle: once seeded, apply the final body once (the last streaming frame may have been throttled)
917-
// so the Y.Doc exactly equals the streamed result; the durable server write then lands as a noop
918-
// diff. If the seed has not arrived, keep `wasStreamingRef` set so the post-seed re-run applies.
932+
// Settle: apply the FINAL body once so the Y.Doc exactly equals the streamed result — even when
933+
// every mid-stream frame was gated out (an `update` rewrite never extends the base) or the stream
934+
// finished before the seed, cases where no frame applied. Reuse the stream's shadow when it exists
935+
// (seeded from the pre-stream base, so peer edits survive); otherwise open one on demand. The
936+
// durable server write then lands as a noop diff.
919937
if (wasStreamingRef.current && collabReady) {
920938
wasStreamingRef.current = false
921939
const finalBody = splitFrontmatter(content).body
922-
const session = agentStreamSessionRef.current
940+
const session = agentStreamSessionRef.current ?? beginAgentStream(editor)
923941
agentStreamSessionRef.current = null
942+
lastStreamedBodyRef.current = null
924943
if (session) {
925944
runOffRender(() => {
926-
if (
927-
finalBody !== lastSyncedBodyRef.current &&
928-
applyAgentStreamFrame(editor, session, finalBody)
929-
) {
930-
lastSyncedBodyRef.current = finalBody
931-
}
945+
applyAgentStreamFrame(editor, session, finalBody)
932946
})
933947
// Free the shadow with an UNGUARDED microtask (not `runOffRender`): a rapid follow-up stream
934948
// can supersede the run token and drop the apply above, but the shadow must always be
@@ -1042,6 +1056,7 @@ export function LoadedRichMarkdownEditor({
10421056
endAgentStream(agentStreamSessionRef.current)
10431057
agentStreamSessionRef.current = null
10441058
}
1059+
lastStreamedBodyRef.current = null
10451060
},
10461061
[]
10471062
)

0 commit comments

Comments
 (0)