Skip to content

Commit 96fd287

Browse files
committed
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.
1 parent 888496f commit 96fd287

4 files changed

Lines changed: 155 additions & 11 deletions

File tree

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

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
@@ -264,6 +270,42 @@ describe('FileDocStore', () => {
264270
bDoc.destroy()
265271
})
266272

273+
it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => {
274+
const streamKey = `filedoc:stream:${NAME}`
275+
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')
276+
// A doc whose content is purely agent preview (no real edit integrated) — realEdited stays false.
277+
const agentDoc = docWithText('agent-only preview body')
278+
const entries = Array.from({ length: 400 }, (_, i) => ({
279+
id: `${i + 1}-0`,
280+
message: { u: noop },
281+
}))
282+
state.backing!.streams.set(streamKey, entries)
283+
state.backing!.seq = 400
284+
285+
const a = await newStore()
286+
;(a as any).rooms.set(NAME, {
287+
doc: agentDoc,
288+
lastId: '400-0',
289+
publishes: 0,
290+
seededObserved: true,
291+
realEdited: false,
292+
})
293+
await (a as any).maybeCompact(NAME)
294+
295+
// The snapshot must carry the AGENT marker, NOT the snapshot marker, so a peer catch-up applies it as
296+
// REDIS_AGENT_ORIGIN and never marks the doc edited — the no-persist guarantee survives compaction.
297+
const stream = state.backing!.streams.get(streamKey)!
298+
const last = stream[stream.length - 1].message
299+
expect(last.a).toBe('1')
300+
expect(last.s).toBeUndefined()
301+
// Content is still fully reconstructable from the compacted stream.
302+
const doc = new Y.Doc()
303+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
304+
expect(doc.getText('body').toString()).toBe('agent-only preview body')
305+
doc.destroy()
306+
agentDoc.destroy()
307+
})
308+
267309
it('retries a transient append failure so the edit is not lost from the shared log', async () => {
268310
const a = await newStore()
269311
state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed
@@ -407,8 +449,20 @@ describe('FileDocStore', () => {
407449
const b = await newStore()
408450
const docA = new Y.Doc()
409451
Y.applyUpdate(docA, peerUpdates[0]) // A integrated up to 401
410-
;(a as any).rooms.set(NAME, { doc: docA, lastId: '401-0', publishes: 0 })
411-
;(b as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 })
452+
;(a as any).rooms.set(NAME, {
453+
doc: docA,
454+
lastId: '401-0',
455+
publishes: 0,
456+
seededObserved: true,
457+
realEdited: true,
458+
})
459+
;(b as any).rooms.set(NAME, {
460+
doc: new Y.Doc(),
461+
lastId: '400-0',
462+
publishes: 0,
463+
seededObserved: true,
464+
realEdited: true,
465+
})
412466
await Promise.all([(a as any).maybeCompact(NAME), (b as any).maybeCompact(NAME)])
413467

414468
const doc = new Y.Doc()

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

Lines changed: 44 additions & 5 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'
@@ -180,13 +180,27 @@ function applyEntryToDoc(
180180
}
181181
}
182182

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+
183189
/** One locally-open room the store tracks: its doc and the last stream id applied to it. */
184190
interface StoreRoom {
185191
doc: Y.Doc
186192
/** The id of the last stream entry applied to `doc`; the tailer resumes strictly after it. */
187193
lastId: string
188194
/** Local publish count, to pace compaction checks. */
189195
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
190204
}
191205

192206
/**
@@ -250,7 +264,13 @@ export class FileDocStore {
250264
if (!this.enabled || !this.write) return
251265
// Register BEFORE the async read so a concurrent publish/tailer for this room can't be missed —
252266
// the tailer resumes from `lastId`, which the catch-up advances.
253-
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+
}
254274
this.rooms.set(name, room)
255275
try {
256276
const entries = await this.write.xRange(streamKey(name), '-', '+')
@@ -298,7 +318,13 @@ export class FileDocStore {
298318
}
299319
await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {})
300320
const room = this.rooms.get(name)
301-
if (room && ++room.publishes % COMPACT_CHECK_EVERY === 0) void this.maybeCompact(name)
321+
if (room) {
322+
// A local non-agent publish (a user edit or the awaited copilot durable merge) is a real edit; the
323+
// seed never flows through here (it uses seedIfEmpty). Set it BEFORE the compaction check below so a
324+
// real edit can never be folded into an agent (no-persist) snapshot due to a tail-back race.
325+
if (!agent) room.realEdited = true
326+
if (++room.publishes % COMPACT_CHECK_EVERY === 0) void this.maybeCompact(name)
327+
}
302328
}
303329

304330
/**
@@ -538,7 +564,16 @@ export class FileDocStore {
538564
: message[AGENT_FIELD]
539565
? REDIS_AGENT_ORIGIN
540566
: REDIS_ORIGIN
567+
const seededBefore = room.seededObserved
541568
applyEntryToDoc(room.doc, id, message, origin)
569+
if (isDocSeeded(room.doc)) room.seededObserved = true
570+
// Track a real edit integrated from the stream so compaction knows whether its snapshot represents
571+
// real content or agent-only preview: a real snapshot (folds real edits), or a markerless edit
572+
// applied AFTER the doc was already seeded (the seed transition itself never counts). Agent frames
573+
// and agent snapshots (REDIS_AGENT_ORIGIN) never count.
574+
if (origin === REDIS_SNAPSHOT_ORIGIN || (origin === REDIS_ORIGIN && seededBefore)) {
575+
room.realEdited = true
576+
}
542577
}
543578

544579
/**
@@ -599,10 +634,14 @@ export class FileDocStore {
599634
// appended snapshot id instead would silently drop those un-integrated peer entries.
600635
const upTo = room.lastId
601636
const snapshot = Buffer.from(Y.encodeStateAsUpdate(room.doc)).toString('base64')
602-
// Mark it a snapshot so a fresh catch-up task treats it as edited content, not a bare seed.
637+
// Stamp the snapshot by what it folds: a real edit → SNAPSHOT_FIELD (a fresh catch-up treats it
638+
// as edited content, not a bare seed). An agent-ONLY stream (no real edit yet) → AGENT_FIELD, so a
639+
// peer catching up applies it as REDIS_AGENT_ORIGIN and never marks the doc edited — preserving
640+
// the no-persist guarantee even when a long copilot stream alone crosses the compaction threshold.
641+
const marker = room.realEdited ? SNAPSHOT_FIELD : AGENT_FIELD
603642
await this.write.xAdd(streamKey(name), '*', {
604643
[UPDATE_FIELD]: snapshot,
605-
[SNAPSHOT_FIELD]: '1',
644+
[marker]: '1',
606645
})
607646
// MINID keeps entries with id >= upTo: the snapshot, any un-integrated peer entries, and
608647
// `upTo` itself (redundant with the snapshot, harmless); it drops only the folded older deltas.

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,46 @@ describe('agent-stream applier', () => {
9999
expect(editor.getText()).toContain('Agent wrote this')
100100
})
101101

102+
it('a shadow reused after the live doc advanced duplicates content; a fresh one does not', () => {
103+
// The invariant behind the component's leadership-regain teardown: a shadow tracks only ITS OWN
104+
// reconciles, so once the live doc advances under another writer, REUSING that stale shadow re-emits
105+
// ops for content already present (duplication). Seeding a FRESH shadow from the current doc fixes it.
106+
const stale = track(makeCollabEditor())
107+
const staleSession = beginAgentStream(stale.editor)! // seeded from the empty base
108+
applyAgentStreamFrame(stale.editor, staleSession, 'Alpha paragraph.')
109+
// Another writer advances the live doc while this shadow is NOT looking (a handoff to an interim leader).
110+
stale.editor.commands.focus('end')
111+
stale.editor.commands.insertContent('\n\nBeta paragraph.')
112+
// Reusing the stale shadow (only knows "Alpha") to reconcile toward the full body re-inserts "Beta".
113+
applyAgentStreamFrame(
114+
stale.editor,
115+
staleSession,
116+
'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.'
117+
)
118+
endAgentStream(staleSession)
119+
const staleText = stale.editor.getText()
120+
expect(staleText.match(/Beta paragraph/g)?.length).toBe(2) // duplicated — what the teardown prevents
121+
122+
// Fresh shadow re-seeded from the CURRENT doc (what a regaining leader does after teardown) emits only
123+
// the genuine delta, so no content duplicates.
124+
const fresh = track(makeCollabEditor())
125+
const first = beginAgentStream(fresh.editor)!
126+
applyAgentStreamFrame(fresh.editor, first, 'Alpha paragraph.')
127+
fresh.editor.commands.focus('end')
128+
fresh.editor.commands.insertContent('\n\nBeta paragraph.')
129+
endAgentStream(first)
130+
const regained = beginAgentStream(fresh.editor)! // re-seeded from the advanced doc
131+
applyAgentStreamFrame(
132+
fresh.editor,
133+
regained,
134+
'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.'
135+
)
136+
endAgentStream(regained)
137+
const freshText = fresh.editor.getText()
138+
expect(freshText.match(/Beta paragraph/g)?.length).toBe(1)
139+
expect(freshText).toContain('Gamma paragraph')
140+
})
141+
102142
it('preserves a concurrent peer edit to a region the agent snapshot does not include', () => {
103143
// This is the core "AI as a CRDT peer" guarantee: the agent relays only its OWN delta (computed
104144
// against a private shadow), never a whole-document reconcile that would revert a peer's edit.

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,15 @@ export function LoadedRichMarkdownEditor({
918918
collaboration &&
919919
!isAgentStreamLeader(collaboration.awareness, collaboration.doc.clientID)
920920
) {
921+
// Not (or no longer) the leader: discard any shadow this client holds. A shadow only tracks
922+
// ITS OWN reconciles, so one kept across a leadership loss goes stale as the interim leader
923+
// advances the shared doc; reusing it on a later regain would re-emit ops for content already
924+
// present (duplication). Dropping it here means a regain rebuilds a FRESH shadow from the
925+
// current doc via the `??=` below — upholding "a non-leader holds none."
926+
if (agentStreamSessionRef.current) {
927+
endAgentStream(agentStreamSessionRef.current)
928+
agentStreamSessionRef.current = null
929+
}
921930
streamRafRef.current = null
922931
return
923932
}
@@ -931,8 +940,10 @@ export function LoadedRichMarkdownEditor({
931940
const el = containerRef.current
932941
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
933942
// Open the shadow lazily HERE — only when THIS client actually leads — seeded from the CURRENT
934-
// doc, so a handoff successor diffs against the prior leader's ops (no stale base) and a
935-
// non-leader never builds one. Defensive: a ready collab editor always has a ySync binding.
943+
// doc. A non-leader holds none (torn down above), so whether this client is a first-time leader
944+
// or one REGAINING leadership, `??=` finds a null ref and rebuilds fresh from the current doc,
945+
// already carrying the interim leader's ops (never a stale base). Defensive: a ready collab
946+
// editor always has a ySync binding.
936947
agentStreamSessionRef.current ??= beginAgentStream(editor)
937948
const session = agentStreamSessionRef.current
938949
if (!session || !applyAgentStreamFrame(editor, session, pending)) {

0 commit comments

Comments
 (0)