Skip to content

Commit f3f7b7a

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

2 files changed

Lines changed: 25 additions & 25 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,11 @@ describe('setupWorkspaceFileDocHandlers', () => {
316316
)
317317
await flushMicrotasks()
318318

319-
// It fans out to the room excluding the sender, so a collaborator sees the stream live...
319+
// It fans out to the WHOLE room — no socket excluded — so peers AND a same-socket sibling provider see
320+
// the stream live (the emitting provider no-ops on its own echo).
320321
const fanout = sent
321322
.slice(before)
322-
.filter((m) => m.event === FILE_DOC_EVENTS.MESSAGE && m.except === 'socket-1')
323+
.filter((m) => m.event === FILE_DOC_EVENTS.MESSAGE && m.except === undefined)
323324
expect(fanout.length).toBeGreaterThan(0)
324325

325326
// ...but it must NOT mark the doc dirty: a last-disconnect flush never persists agent content (the

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

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -179,22 +179,14 @@ function originSocketId(origin: unknown): string | null {
179179

180180
/**
181181
* The transaction origin stamped on an agent-streamed frame (a {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}
182-
* apply). It carries the emitting socket id for broadcast exclusion, but is deliberately NOT a plain
183-
* string — so `originSocketId` returns `null` for it and the update never triggers `edited`/`schedulePersist`
184-
* (the copilot's final `edit_content` write is the durable persist).
182+
* apply). A non-string sentinel, so `originSocketId` returns `null` for it and the update never triggers
183+
* `edited`/`schedulePersist` (the copilot's final `edit_content` write is the durable persist). Unlike a
184+
* client edit, an agent frame is broadcast to the WHOLE room (its originating socket is NOT excluded), so a
185+
* second {@link FileDocProvider} on the same socket — e.g. the chat preview alongside the Files editor —
186+
* also receives the mid-stream ops. The emitting provider no-ops on its own echo (the ops are already
187+
* applied locally), so broadcasting back to the sender is harmless.
185188
*/
186-
interface AgentSyncOrigin {
187-
readonly agentSocketId: string
188-
}
189-
190-
function isAgentSyncOrigin(origin: unknown): origin is AgentSyncOrigin {
191-
return typeof origin === 'object' && origin !== null && 'agentSocketId' in origin
192-
}
193-
194-
/** The socket id to exclude when relaying an update — a client socket edit OR an agent-streamed frame. */
195-
function excludeSocketId(origin: unknown): string | null {
196-
return originSocketId(origin) ?? (isAgentSyncOrigin(origin) ? origin.agentSocketId : null)
197-
}
189+
const AGENT_SYNC_ORIGIN = Symbol('file-doc-agent-sync')
198190

199191
/**
200192
* Broadcast an AWARENESS frame to the room ACROSS tasks via the Socket.IO Redis adapter. Awareness
@@ -706,7 +698,15 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
706698
// Fan out to THIS task's clients only (excluding the origin socket if local — a user edit OR an
707699
// agent-streamed frame). Cross-task delivery rides the shared stream — every task's tailer applies +
708700
// runs its own local fan-out.
709-
broadcastLocal(io, name, encoding.toUint8Array(encoder), excludeSocketId(origin))
701+
// A client edit excludes its own sender socket (echo suppression). An agent frame broadcasts to the
702+
// WHOLE room — no socket excluded — so a same-socket sibling provider (chat preview + Files editor)
703+
// stays live mid-stream; the emitting provider no-ops on its own echo.
704+
broadcastLocal(
705+
io,
706+
name,
707+
encoding.toUint8Array(encoder),
708+
origin === AGENT_SYNC_ORIGIN ? null : originSocketId(origin)
709+
)
710710
// Share every locally-originated update to the stream so peers converge. Skip updates that already
711711
// came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN) and SEED_ORIGIN — the seed is published
712712
// EXPLICITLY and AWAITED under the seed lock (so it lands before the lock releases), which a
@@ -720,7 +720,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
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:
722722
// 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
723+
// ORIGINATING task (it applies under {@link AGENT_SYNC_ORIGIN} — see the handler), but in the multi-replica
724724
// path it is published to the stream and PEER tasks apply it as REDIS_ORIGIN, indistinguishable from a
725725
// peer edit, so it marks `edited` there. That only ever causes an extra idempotent persist of content
726726
// the copilot's final `edit_content` write persists durably anyway (safe over-persist, never a lost
@@ -804,16 +804,15 @@ function handleMessage(socket: AuthenticatedSocket, data: unknown) {
804804
break
805805
}
806806
case FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST: {
807-
// An agent-streamed frame: apply + fan out to peers (so a collaborator sees the stream live) but
807+
// An agent-streamed frame: apply + fan out to the room (so a collaborator sees the stream live) but
808808
// do NOT treat it as a durable user edit. Unlike SYNC, we do NOT set `lastEditorUserId`, and the
809-
// apply uses an {@link AgentSyncOrigin} (not the bare socket id) so `originSocketId` is `null` in
810-
// `doc.on('update')` — skipping `edited`/`schedulePersist`. `excludeSocketId` still reads the
811-
// carried socket id, so the sender is excluded from the relay fan-out. The copilot's final
809+
// apply uses {@link AGENT_SYNC_ORIGIN} (a non-string sentinel) so `originSocketId` is `null` in
810+
// `doc.on('update')` — skipping `edited`/`schedulePersist`, and broadcasting to the WHOLE room
811+
// (including the sender socket, so a same-socket sibling provider stays live). The copilot's final
812812
// `edit_content` write remains the authoritative durable persist.
813813
const encoder = encoding.createEncoder()
814814
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
815-
const agentOrigin: AgentSyncOrigin = { agentSocketId: socket.id }
816-
syncProtocol.readSyncMessage(decoder, encoder, room.doc, agentOrigin)
815+
syncProtocol.readSyncMessage(decoder, encoder, room.doc, AGENT_SYNC_ORIGIN)
817816
if (encoding.length(encoder) > 1) {
818817
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder))
819818
}

0 commit comments

Comments
 (0)