@@ -105,6 +105,13 @@ interface FileDocRoom {
105105 workspaceId : string | null
106106 /** The last collaborator to edit here, for persist attribution (blob metadata) only. */
107107 lastEditorUserId : string | null
108+ /**
109+ * True once a genuine USER edit has been applied here. Persistence is gated on it so a doc that was
110+ * only seeded (or only received a copilot merge) is NEVER projected back over the file: copilot writes
111+ * the file durably itself, and a seed captured from possibly-stale markdown must not clobber a
112+ * concurrent external write.
113+ */
114+ edited : boolean
108115 /** The pending debounced persist timer, if any. */
109116 persistTimer : ReturnType < typeof setTimeout > | null
110117}
@@ -200,7 +207,8 @@ function schedulePersist(name: string, room: FileDocRoom): void {
200207 * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative.
201208 */
202209async function flushPersist ( name : string , room : FileDocRoom , final : boolean ) : Promise < void > {
203- if ( ! room . workspaceId || ! room . lastEditorUserId ) return
210+ // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}).
211+ if ( ! room . edited || ! room . workspaceId || ! room . lastEditorUserId ) return
204212 const store = getFileDocStore ( )
205213 // Synchronous fallback capture — before any await, since the caller may destroy `room.doc` the moment
206214 // this yields. Only meaningful once seeded; used only when the authoritative stream state is absent.
@@ -456,6 +464,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
456464 serverSeedStarted : false ,
457465 workspaceId : null ,
458466 lastEditorUserId : null ,
467+ edited : false ,
459468 persistTimer : null ,
460469 }
461470 // Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second.
@@ -472,9 +481,13 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
472481 // in the stream) and SEED_ORIGIN — the seed is published EXPLICITLY and AWAITED under the seed lock
473482 // (so it lands before the lock releases), which a fire-and-forget publish here couldn't guarantee.
474483 if ( origin !== REDIS_ORIGIN && origin !== SEED_ORIGIN ) getFileDocStore ( ) . publish ( name , update )
475- // Persist real edits (user edits + copilot merges) back to markdown, debounced. Skip the seed (it
476- // is the file's current content) and stream-relayed updates (their originating task persists them).
477- if ( origin !== REDIS_ORIGIN && origin !== SEED_ORIGIN ) schedulePersist ( name , room )
484+ // Persist ONLY genuine USER edits (socket origin), debounced. A seed or a bare copilot merge must
485+ // NOT project back over the file — copilot writes the file durably itself, so persisting a
486+ // seeded-but-unedited doc (built from possibly-stale markdown) could clobber that concurrent write.
487+ if ( originSocketId ( origin ) ) {
488+ room . edited = true
489+ schedulePersist ( name , room )
490+ }
478491 } )
479492
480493 awareness . on ( 'update' , ( { added, updated, removed } : AwarenessChange , origin : unknown ) => {
0 commit comments