Skip to content

Commit 1a995ff

Browse files
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/file-doc-store.test.ts

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function makeClient(): any {
100100

101101
vi.mock('redis', () => ({ createClient: () => makeClient() }))
102102

103-
import { FileDocStore } from '@/handlers/file-doc-store'
103+
import { FileDocStore, REDIS_AGENT_ORIGIN, REDIS_ORIGIN } from '@/handlers/file-doc-store'
104104

105105
const REDIS_URL = 'redis://fake'
106106
const NAME = 'workspace-file-doc:file-1'
@@ -223,8 +223,14 @@ describe('FileDocStore', () => {
223223

224224
const a = await newStore()
225225
// This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the
226-
// two peer entries. Inject that lagging room directly.
227-
;(a as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 })
226+
// two peer entries. Inject that lagging room directly (a real edit was integrated → realEdited).
227+
;(a as any).rooms.set(NAME, {
228+
doc: new Y.Doc(),
229+
lastId: '400-0',
230+
publishes: 0,
231+
seededObserved: true,
232+
realEdited: true,
233+
})
228234
await (a as any).maybeCompact(NAME)
229235

230236
// A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402.
@@ -239,6 +245,84 @@ describe('FileDocStore', () => {
239245
expect(stream[stream.length - 1].message.s).toBe('1')
240246
})
241247

248+
it('tags an agent-streamed frame so a peer tailer applies it as REDIS_AGENT_ORIGIN (never persisted)', async () => {
249+
const streamKey = `filedoc:stream:${NAME}`
250+
const a = await newStore()
251+
const b = await newStore()
252+
const bDoc = new Y.Doc()
253+
// Capture the origin the tailer stamps each applied entry with — the persistence gate keys off it.
254+
const origins: unknown[] = []
255+
bDoc.on('update', (_u: Uint8Array, origin: unknown) => origins.push(origin))
256+
await b.attachRoom(NAME, bDoc)
257+
258+
// A normal edit tails as REDIS_ORIGIN (a peer edit that CAN be persisted).
259+
a.publish(NAME, updateFor('user edit'))
260+
await vi.waitFor(() => expect(origins).toContain(REDIS_ORIGIN), { timeout: 2000 })
261+
262+
// An agent-streamed frame is published WITH the agent flag: the stream entry carries the marker, and
263+
// the peer tailer applies it as REDIS_AGENT_ORIGIN — excluded from the relay's edited/persist gate.
264+
a.publish(NAME, updateFor('agent frame'), true)
265+
await vi.waitFor(() => expect(origins).toContain(REDIS_AGENT_ORIGIN), { timeout: 2000 })
266+
const stream = state.backing!.streams.get(streamKey)!
267+
expect(stream.some((e) => e.message.a === '1')).toBe(true)
268+
// The normal edit's entry carries no agent marker.
269+
expect(stream.filter((e) => e.message.a === '1')).toHaveLength(1)
270+
bDoc.destroy()
271+
})
272+
273+
it('latches realEdited synchronously so a concurrent compaction can never mislabel a real edit', async () => {
274+
// The data-loss race: a real edit sits in room.doc synchronously, but if realEdited were set only
275+
// AFTER appendUpdate's awaits, a concurrent agent-triggered compaction could snapshot that content and
276+
// stamp it an agent (no-persist) frame — losing the edit. The latch must be set in the same tick.
277+
const a = await newStore()
278+
const doc = new Y.Doc()
279+
await a.attachRoom(NAME, doc)
280+
const room = (a as any).rooms.get(NAME)
281+
expect(room.realEdited).toBe(false)
282+
// Kick off a real (non-agent) append but do NOT await it: realEdited must already be true before the
283+
// xAdd/expire awaits resolve, so any compaction racing on the awaits sees the real edit.
284+
const pending = (a as any).appendUpdate(NAME, updateFor('real user edit'))
285+
expect(room.realEdited).toBe(true)
286+
await pending
287+
doc.destroy()
288+
})
289+
290+
it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => {
291+
const streamKey = `filedoc:stream:${NAME}`
292+
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')
293+
// A doc whose content is purely agent preview (no real edit integrated) — realEdited stays false.
294+
const agentDoc = docWithText('agent-only preview body')
295+
const entries = Array.from({ length: 400 }, (_, i) => ({
296+
id: `${i + 1}-0`,
297+
message: { u: noop },
298+
}))
299+
state.backing!.streams.set(streamKey, entries)
300+
state.backing!.seq = 400
301+
302+
const a = await newStore()
303+
;(a as any).rooms.set(NAME, {
304+
doc: agentDoc,
305+
lastId: '400-0',
306+
publishes: 0,
307+
seededObserved: true,
308+
realEdited: false,
309+
})
310+
await (a as any).maybeCompact(NAME)
311+
312+
// The snapshot must carry the AGENT marker, NOT the snapshot marker, so a peer catch-up applies it as
313+
// REDIS_AGENT_ORIGIN and never marks the doc edited — the no-persist guarantee survives compaction.
314+
const stream = state.backing!.streams.get(streamKey)!
315+
const last = stream[stream.length - 1].message
316+
expect(last.a).toBe('1')
317+
expect(last.s).toBeUndefined()
318+
// Content is still fully reconstructable from the compacted stream.
319+
const doc = new Y.Doc()
320+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
321+
expect(doc.getText('body').toString()).toBe('agent-only preview body')
322+
doc.destroy()
323+
agentDoc.destroy()
324+
})
325+
242326
it('retries a transient append failure so the edit is not lost from the shared log', async () => {
243327
const a = await newStore()
244328
state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed
@@ -382,8 +466,20 @@ describe('FileDocStore', () => {
382466
const b = await newStore()
383467
const docA = new Y.Doc()
384468
Y.applyUpdate(docA, peerUpdates[0]) // A integrated up to 401
385-
;(a as any).rooms.set(NAME, { doc: docA, lastId: '401-0', publishes: 0 })
386-
;(b as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 })
469+
;(a as any).rooms.set(NAME, {
470+
doc: docA,
471+
lastId: '401-0',
472+
publishes: 0,
473+
seededObserved: true,
474+
realEdited: true,
475+
})
476+
;(b as any).rooms.set(NAME, {
477+
doc: new Y.Doc(),
478+
lastId: '400-0',
479+
publishes: 0,
480+
seededObserved: true,
481+
realEdited: true,
482+
})
387483
await Promise.all([(a as any).maybeCompact(NAME), (b as any).maybeCompact(NAME)])
388484

389485
const doc = new Y.Doc()

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

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* @module
3434
*/
3535
import { createLogger } from '@sim/logger'
36-
import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
36+
import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc'
3737
import { getErrorMessage } from '@sim/utils/errors'
3838
import { sleep } from '@sim/utils/helpers'
3939
import { generateId } from '@sim/utils/id'
@@ -90,6 +90,16 @@ export const REDIS_ORIGIN = Symbol('file-doc-redis')
9090
*/
9191
export const REDIS_SNAPSHOT_ORIGIN = Symbol('file-doc-redis-snapshot')
9292

93+
/**
94+
* Origin for an AGENT-STREAMED frame applied from the stream (a copilot output token relayed via
95+
* {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}). A peer task tails these to stay live mid-stream, but
96+
* they are transient preview content the copilot's durable `edit_content` write reconciles — so the
97+
* relay's edit-tracker must NOT mark the doc edited on them (a startup-race duplicate between two stream
98+
* leaders would otherwise become eligible for a peer task's persist). Behaves like {@link REDIS_ORIGIN}
99+
* otherwise (already in the stream — never re-published).
100+
*/
101+
export const REDIS_AGENT_ORIGIN = Symbol('file-doc-redis-agent')
102+
93103
const STREAM_PREFIX = 'filedoc:stream:'
94104
/** Cluster-wide "durable version the live doc is synced to" (the persist If-Match token). */
95105
const SYNC_VERSION_PREFIX = 'filedoc:syncver:'
@@ -103,6 +113,9 @@ const UPDATE_FIELD = 'u'
103113
/** Marks a stream entry as a compaction SNAPSHOT (folds seed + edits), so the tailer applies it with
104114
* {@link REDIS_SNAPSHOT_ORIGIN}. Present only on snapshot entries. */
105115
const SNAPSHOT_FIELD = 's'
116+
/** Marks a stream entry as an AGENT-STREAMED preview frame, so the tailer applies it with
117+
* {@link REDIS_AGENT_ORIGIN} (never marks the doc edited). Present only on agent-frame entries. */
118+
const AGENT_FIELD = 'a'
106119

107120
/** Sentinel token a DISABLED store returns from a lock acquire, so single-replica callers proceed
108121
* without special-casing; {@link FileDocStore.releaseLock} treats it as a no-op. Not a real UUID, so it
@@ -167,13 +180,27 @@ function applyEntryToDoc(
167180
}
168181
}
169182

183+
/** Whether a doc carries the seed flag (mirrors the relay's `isDocSeeded`), so the store can tell the
184+
* one-time seed transition from a real post-seed edit without re-implementing the check divergently. */
185+
function isDocSeeded(doc: Y.Doc): boolean {
186+
return doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag) === true
187+
}
188+
170189
/** One locally-open room the store tracks: its doc and the last stream id applied to it. */
171190
interface StoreRoom {
172191
doc: Y.Doc
173192
/** The id of the last stream entry applied to `doc`; the tailer resumes strictly after it. */
174193
lastId: string
175194
/** Local publish count, to pace compaction checks. */
176195
publishes: number
196+
/** Set once the doc has been observed seeded, so the seed transition itself is never mistaken for an
197+
* edit (mirrors the relay's `seededObserved`). */
198+
seededObserved: boolean
199+
/** Whether the doc has integrated any REAL (non-agent, non-seed) edit. Compaction stamps its snapshot
200+
* as an AGENT snapshot ({@link REDIS_AGENT_ORIGIN}, never persisted) until this is true, so a long
201+
* agent-only stream that crosses the compaction threshold can't fold its preview content into a
202+
* snapshot that marks peers edited. */
203+
realEdited: boolean
177204
}
178205

179206
/**
@@ -237,7 +264,13 @@ export class FileDocStore {
237264
if (!this.enabled || !this.write) return
238265
// Register BEFORE the async read so a concurrent publish/tailer for this room can't be missed —
239266
// the tailer resumes from `lastId`, which the catch-up advances.
240-
const room: StoreRoom = { doc, lastId: '0', publishes: 0 }
267+
const room: StoreRoom = {
268+
doc,
269+
lastId: '0',
270+
publishes: 0,
271+
seededObserved: false,
272+
realEdited: false,
273+
}
241274
this.rooms.set(name, room)
242275
try {
243276
const entries = await this.write.xRange(streamKey(name), '-', '+')
@@ -264,12 +297,25 @@ export class FileDocStore {
264297
* an edit from the shared log. Only the `xAdd` is retried; the TTL refresh + compaction check are
265298
* post-write best-effort and never re-trigger the append. Throws if the append ultimately fails.
266299
*/
267-
private async appendUpdate(name: string, update: Uint8Array): Promise<void> {
300+
private async appendUpdate(name: string, update: Uint8Array, agent = false): Promise<void> {
268301
if (!this.write) return
302+
// Latch realEdited SYNCHRONOUSLY — before the first await — for a real (non-agent) publish. The edit
303+
// already sits in room.doc (applied in doc.on('update') before publish was called), so if this set
304+
// were deferred past the xAdd/expire awaits a CONCURRENT agent-frame-triggered maybeCompact could read
305+
// realEdited=false, snapshot the doc (which already holds this real edit), and stamp it an agent
306+
// (no-persist) snapshot — a lost edit. Setting it in the same synchronous tick as the doc mutation
307+
// makes "room.doc holds a real edit ⇒ realEdited" hold before any compaction (always async) can run.
308+
// Monotonic latch, so an eager set is safe; the seed never flows through here (it uses seedIfEmpty).
309+
if (!agent) {
310+
const editedRoom = this.rooms.get(name)
311+
if (editedRoom) editedRoom.realEdited = true
312+
}
269313
const encoded = Buffer.from(update).toString('base64')
314+
const fields: Record<string, string> = { [UPDATE_FIELD]: encoded }
315+
if (agent) fields[AGENT_FIELD] = '1'
270316
for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) {
271317
try {
272-
await this.write.xAdd(streamKey(name), '*', { [UPDATE_FIELD]: encoded })
318+
await this.write.xAdd(streamKey(name), '*', fields)
273319
break
274320
} catch (error) {
275321
if (attempt === PUBLISH_MAX_RETRIES) {
@@ -288,11 +334,12 @@ export class FileDocStore {
288334

289335
/**
290336
* Fire-and-forget append for the hot keystroke path (`doc.on('update')`): converges peers without
291-
* blocking the relay. Retries internally; never throws. No-op when disabled.
337+
* blocking the relay. Retries internally; never throws. No-op when disabled. Pass `agent: true` for
338+
* a copilot preview frame so peer tasks tail it as {@link REDIS_AGENT_ORIGIN} and never persist it.
292339
*/
293-
publish(name: string, update: Uint8Array): void {
340+
publish(name: string, update: Uint8Array, agent = false): void {
294341
if (!this.enabled || !this.write) return
295-
void this.appendUpdate(name, update).catch(() => {}) // already logged inside appendUpdate
342+
void this.appendUpdate(name, update, agent).catch(() => {}) // already logged inside appendUpdate
296343
}
297344

298345
/**
@@ -515,9 +562,23 @@ export class FileDocStore {
515562
private applyEntry(room: StoreRoom, id: string, message: Record<string, string>): void {
516563
room.lastId = id
517564
// A compaction snapshot folds seed + edits into one frame; stamp it so the relay's edit-tracker
518-
// treats a fresh catch-up from it as edited (a snapshot only exists once real edits accumulated).
519-
const origin = message[SNAPSHOT_FIELD] ? REDIS_SNAPSHOT_ORIGIN : REDIS_ORIGIN
565+
// treats a fresh catch-up from it as edited (a snapshot only exists once real edits accumulated). An
566+
// agent-streamed preview frame is stamped separately so the tracker NEVER marks it edited.
567+
const origin = message[SNAPSHOT_FIELD]
568+
? REDIS_SNAPSHOT_ORIGIN
569+
: message[AGENT_FIELD]
570+
? REDIS_AGENT_ORIGIN
571+
: REDIS_ORIGIN
572+
const seededBefore = room.seededObserved
520573
applyEntryToDoc(room.doc, id, message, origin)
574+
if (isDocSeeded(room.doc)) room.seededObserved = true
575+
// Track a real edit integrated from the stream so compaction knows whether its snapshot represents
576+
// real content or agent-only preview: a real snapshot (folds real edits), or a markerless edit
577+
// applied AFTER the doc was already seeded (the seed transition itself never counts). Agent frames
578+
// and agent snapshots (REDIS_AGENT_ORIGIN) never count.
579+
if (origin === REDIS_SNAPSHOT_ORIGIN || (origin === REDIS_ORIGIN && seededBefore)) {
580+
room.realEdited = true
581+
}
521582
}
522583

523584
/**
@@ -578,10 +639,14 @@ export class FileDocStore {
578639
// appended snapshot id instead would silently drop those un-integrated peer entries.
579640
const upTo = room.lastId
580641
const snapshot = Buffer.from(Y.encodeStateAsUpdate(room.doc)).toString('base64')
581-
// Mark it a snapshot so a fresh catch-up task treats it as edited content, not a bare seed.
642+
// Stamp the snapshot by what it folds: a real edit → SNAPSHOT_FIELD (a fresh catch-up treats it
643+
// as edited content, not a bare seed). An agent-ONLY stream (no real edit yet) → AGENT_FIELD, so a
644+
// peer catching up applies it as REDIS_AGENT_ORIGIN and never marks the doc edited — preserving
645+
// the no-persist guarantee even when a long copilot stream alone crosses the compaction threshold.
646+
const marker = room.realEdited ? SNAPSHOT_FIELD : AGENT_FIELD
582647
await this.write.xAdd(streamKey(name), '*', {
583648
[UPDATE_FIELD]: snapshot,
584-
[SNAPSHOT_FIELD]: '1',
649+
[marker]: '1',
585650
})
586651
// MINID keeps entries with id >= upTo: the snapshot, any un-integrated peer entries, and
587652
// `upTo` itself (redundant with the snapshot, harmless); it drops only the folded older deltas.

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,31 +60,27 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering'
6060
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
6161
})
6262

63-
it('drops a stale-base streaming snapshot against the SHARED synced version and never records it', async () => {
63+
it('drops a stale durable write against the SHARED synced version', async () => {
6464
// A durable write (e.g. a concurrent human save on another process) records the shared synced version.
6565
expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe(
6666
'applied'
6767
)
6868
expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100)
6969
mockFetchFileDocMerge.mockClear()
7070

71-
// A streaming snapshot built from an older base (50) than the SHARED synced version is stale —
72-
// rejected under the lock before any diff is built, so it can't clobber the durable write.
73-
expect(
74-
await applyMarkdownToLiveFileDoc('file-1', '# stale-base stream', { baseVersion: 50 })
75-
).toBe('stale')
71+
// A durable write with an OLDER version than the SHARED synced version is stale — rejected under the
72+
// lock before any diff is built, so it can't regress the doc across replicas.
73+
expect(await applyMarkdownToLiveFileDoc('file-1', '# older durable', { version: 50 })).toBe(
74+
'stale'
75+
)
7676
expect(mockFetchFileDocMerge).not.toHaveBeenCalled()
7777

78-
// A streaming snapshot whose base is the current shared version applies (nothing newer to clobber)...
79-
expect(
80-
await applyMarkdownToLiveFileDoc('file-1', '# current-base stream', { baseVersion: 100 })
81-
).toBe('applied')
82-
// ...but is never recorded: a later durable write at 150 still applies.
78+
// A newer durable write applies and advances the shared synced version.
8379
expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe(
8480
'applied'
8581
)
8682
expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150)
87-
// setSyncedVersion fired only for the two durable writes, never for a streaming snapshot.
83+
// setSyncedVersion fired only for the two applied durable writes, never for the stale one.
8884
expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2)
8985
})
9086
})

0 commit comments

Comments
 (0)