Skip to content

Commit 888496f

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

3 files changed

Lines changed: 78 additions & 21 deletions

File tree

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

Lines changed: 26 additions & 1 deletion
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'
@@ -239,6 +239,31 @@ describe('FileDocStore', () => {
239239
expect(stream[stream.length - 1].message.s).toBe('1')
240240
})
241241

242+
it('tags an agent-streamed frame so a peer tailer applies it as REDIS_AGENT_ORIGIN (never persisted)', async () => {
243+
const streamKey = `filedoc:stream:${NAME}`
244+
const a = await newStore()
245+
const b = await newStore()
246+
const bDoc = new Y.Doc()
247+
// Capture the origin the tailer stamps each applied entry with — the persistence gate keys off it.
248+
const origins: unknown[] = []
249+
bDoc.on('update', (_u: Uint8Array, origin: unknown) => origins.push(origin))
250+
await b.attachRoom(NAME, bDoc)
251+
252+
// A normal edit tails as REDIS_ORIGIN (a peer edit that CAN be persisted).
253+
a.publish(NAME, updateFor('user edit'))
254+
await vi.waitFor(() => expect(origins).toContain(REDIS_ORIGIN), { timeout: 2000 })
255+
256+
// An agent-streamed frame is published WITH the agent flag: the stream entry carries the marker, and
257+
// the peer tailer applies it as REDIS_AGENT_ORIGIN — excluded from the relay's edited/persist gate.
258+
a.publish(NAME, updateFor('agent frame'), true)
259+
await vi.waitFor(() => expect(origins).toContain(REDIS_AGENT_ORIGIN), { timeout: 2000 })
260+
const stream = state.backing!.streams.get(streamKey)!
261+
expect(stream.some((e) => e.message.a === '1')).toBe(true)
262+
// The normal edit's entry carries no agent marker.
263+
expect(stream.filter((e) => e.message.a === '1')).toHaveLength(1)
264+
bDoc.destroy()
265+
})
266+
242267
it('retries a transient append failure so the edit is not lost from the shared log', async () => {
243268
const a = await newStore()
244269
state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed

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

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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
@@ -264,12 +277,14 @@ export class FileDocStore {
264277
* an edit from the shared log. Only the `xAdd` is retried; the TTL refresh + compaction check are
265278
* post-write best-effort and never re-trigger the append. Throws if the append ultimately fails.
266279
*/
267-
private async appendUpdate(name: string, update: Uint8Array): Promise<void> {
280+
private async appendUpdate(name: string, update: Uint8Array, agent = false): Promise<void> {
268281
if (!this.write) return
269282
const encoded = Buffer.from(update).toString('base64')
283+
const fields: Record<string, string> = { [UPDATE_FIELD]: encoded }
284+
if (agent) fields[AGENT_FIELD] = '1'
270285
for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) {
271286
try {
272-
await this.write.xAdd(streamKey(name), '*', { [UPDATE_FIELD]: encoded })
287+
await this.write.xAdd(streamKey(name), '*', fields)
273288
break
274289
} catch (error) {
275290
if (attempt === PUBLISH_MAX_RETRIES) {
@@ -288,11 +303,12 @@ export class FileDocStore {
288303

289304
/**
290305
* 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.
306+
* blocking the relay. Retries internally; never throws. No-op when disabled. Pass `agent: true` for
307+
* a copilot preview frame so peer tasks tail it as {@link REDIS_AGENT_ORIGIN} and never persist it.
292308
*/
293-
publish(name: string, update: Uint8Array): void {
309+
publish(name: string, update: Uint8Array, agent = false): void {
294310
if (!this.enabled || !this.write) return
295-
void this.appendUpdate(name, update).catch(() => {}) // already logged inside appendUpdate
311+
void this.appendUpdate(name, update, agent).catch(() => {}) // already logged inside appendUpdate
296312
}
297313

298314
/**
@@ -515,8 +531,13 @@ export class FileDocStore {
515531
private applyEntry(room: StoreRoom, id: string, message: Record<string, string>): void {
516532
room.lastId = id
517533
// 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
534+
// treats a fresh catch-up from it as edited (a snapshot only exists once real edits accumulated). An
535+
// agent-streamed preview frame is stamped separately so the tracker NEVER marks it edited.
536+
const origin = message[SNAPSHOT_FIELD]
537+
? REDIS_SNAPSHOT_ORIGIN
538+
: message[AGENT_FIELD]
539+
? REDIS_AGENT_ORIGIN
540+
: REDIS_ORIGIN
520541
applyEntryToDoc(room.doc, id, message, origin)
521542
}
522543

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ import * as syncProtocol from 'y-protocols/sync'
4545
import * as Y from 'yjs'
4646
import { resolveAvatarUrl } from '@/handlers/avatar'
4747
import { fetchFileDocMerge, fetchFileDocPersist, fetchFileDocSeed } from '@/handlers/file-doc-app'
48-
import { getFileDocStore, REDIS_ORIGIN, REDIS_SNAPSHOT_ORIGIN } from '@/handlers/file-doc-store'
48+
import {
49+
getFileDocStore,
50+
REDIS_AGENT_ORIGIN,
51+
REDIS_ORIGIN,
52+
REDIS_SNAPSHOT_ORIGIN,
53+
} from '@/handlers/file-doc-store'
4954
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
5055
import type { AuthenticatedSocket } from '@/middleware/auth'
5156
import type { IRoomManager } from '@/rooms'
@@ -708,23 +713,29 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
708713
origin === AGENT_SYNC_ORIGIN ? null : originSocketId(origin)
709714
)
710715
// Share every locally-originated update to the stream so peers converge. Skip updates that already
711-
// came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN) and SEED_ORIGIN — the seed is published
712-
// EXPLICITLY and AWAITED under the seed lock (so it lands before the lock releases), which a
713-
// fire-and-forget publish here couldn't guarantee.
714-
if (origin !== REDIS_ORIGIN && origin !== REDIS_SNAPSHOT_ORIGIN && origin !== SEED_ORIGIN)
715-
getFileDocStore().publish(name, update)
716+
// came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN / REDIS_AGENT_ORIGIN) and SEED_ORIGIN —
717+
// the seed is published EXPLICITLY and AWAITED under the seed lock (so it lands before the lock
718+
// releases), which a fire-and-forget publish here couldn't guarantee. An agent-streamed frame
719+
// (AGENT_SYNC_ORIGIN) is published WITH the agent marker so peer tasks tail it as REDIS_AGENT_ORIGIN
720+
// and never mark the doc edited on it (see the edit-tracker below).
721+
if (
722+
origin !== REDIS_ORIGIN &&
723+
origin !== REDIS_SNAPSHOT_ORIGIN &&
724+
origin !== REDIS_AGENT_ORIGIN &&
725+
origin !== SEED_ORIGIN
726+
)
727+
getFileDocStore().publish(name, update, origin === AGENT_SYNC_ORIGIN)
716728
// Edit tracking for persistence. Mark the doc dirty on any update applied AFTER it was seeded — a
717729
// local user edit (socket origin) OR a peer's edit relayed via the tailer (REDIS_ORIGIN) — so
718730
// whichever task is last to leave persists real edits, even one that only tailed them. A compaction
719731
// snapshot on catch-up (REDIS_SNAPSHOT_ORIGIN) also counts: it folds real edits into one frame, so a
720732
// fresh task catching up purely from it must not treat the doc as unedited. The seed transition
721-
// itself is never counted, so a seeded-but-unedited doc is never projected back over the file. NOTE:
722-
// an agent-streamed frame ({@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}) is not counted on the
723-
// ORIGINATING task (it applies under {@link AGENT_SYNC_ORIGIN} — 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.
733+
// itself is never counted, so a seeded-but-unedited doc is never projected back over the file. An
734+
// agent-streamed frame ({@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}) is never counted anywhere: on
735+
// the ORIGINATING task it applies under {@link AGENT_SYNC_ORIGIN}, and across replicas it is published
736+
// WITH the agent marker so PEER tasks tail it as REDIS_AGENT_ORIGIN — neither is in the edited set
737+
// below. So a transient startup-race duplicate between two stream leaders is never eligible for
738+
// persistence; the copilot's durable `edit_content` write remains the sole authority over file bytes.
728739
const seededBefore = room.seededObserved
729740
if (isDocSeeded(room.doc)) room.seededObserved = true
730741
if (

0 commit comments

Comments
 (0)