@@ -106,12 +106,16 @@ interface FileDocRoom {
106106 /** The last collaborator to edit here, for persist attribution (blob metadata) only. */
107107 lastEditorUserId : string | null
108108 /**
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.
109+ * True once a genuine edit (a user edit — local OR a peer's, relayed via the tailer) has been applied
110+ * here AFTER seeding. Persistence is gated on it so a doc that was only seeded is NEVER projected back
111+ * over the file: the seed is captured from possibly-stale markdown and must not clobber a concurrent
112+ * external write, whereas real edits are not otherwise durable and must be persisted by whichever task
113+ * is last to leave — even one that only tailed the edits.
113114 */
114115 edited : boolean
116+ /** Whether this room has observed its doc become seeded — so a post-seed update counts as an edit but
117+ * the seed transition itself does not. See the `doc.on('update')` edit-tracking below. */
118+ seededObserved : boolean
115119 /** The pending debounced persist timer, if any. */
116120 persistTimer : ReturnType < typeof setTimeout > | null
117121}
@@ -343,7 +347,13 @@ async function ensureServerSeed(
343347 if ( fileDocRooms . get ( name ) !== room || isDocSeeded ( room . doc ) ) return
344348 // Fence against a peer that seeded while we held a stale lock (a rare long stall): if the stream
345349 // already has content it is seeded, so never write a SECOND seed on top — that would split-brain.
346- if ( await store . streamHasContent ( name ) ) return
350+ if ( await store . streamHasContent ( name ) ) {
351+ // Clear the guard so a later join can retry. Safe in both cases: a genuine peer-seed arrives via
352+ // the tailer (and shouldSeed/this fence then no-op), and a fail-closed Redis `xLen` error must not
353+ // strand the room unseeded forever.
354+ room . serverSeedStarted = false
355+ return
356+ }
347357 // Build the seed (file content + seed flag, or just the flag for an empty/missing file), then
348358 // PUBLISH it to the shared stream AWAITED *before* seeding the local doc. The doc is marked seeded
349359 // only once the seed is durably shared — so if the publish fails we leave the doc unseeded and the
@@ -486,6 +496,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
486496 workspaceId : null ,
487497 lastEditorUserId : null ,
488498 edited : false ,
499+ seededObserved : false ,
489500 persistTimer : null ,
490501 }
491502 // Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second.
@@ -502,13 +513,16 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
502513 // in the stream) and SEED_ORIGIN — the seed is published EXPLICITLY and AWAITED under the seed lock
503514 // (so it lands before the lock releases), which a fire-and-forget publish here couldn't guarantee.
504515 if ( origin !== REDIS_ORIGIN && origin !== SEED_ORIGIN ) getFileDocStore ( ) . publish ( name , update )
505- // Persist ONLY genuine USER edits (socket origin), debounced. A seed or a bare copilot merge must
506- // NOT project back over the file — copilot writes the file durably itself, so persisting a
507- // seeded-but-unedited doc (built from possibly-stale markdown) could clobber that concurrent write.
508- if ( originSocketId ( origin ) ) {
509- room . edited = true
510- schedulePersist ( name , room )
511- }
516+ // Edit tracking for persistence. Mark the doc dirty on any update applied AFTER it was seeded — a
517+ // local user edit (socket origin) OR a peer's edit relayed via the tailer (REDIS_ORIGIN) — so
518+ // whichever task is last to leave persists real edits, even one that only tailed them. The seed
519+ // transition itself is never counted (nor a purely-local copilot merge, already durable via
520+ // copilot's direct file write), so a seeded-but-unedited doc is never projected back over the file.
521+ const seededBefore = room . seededObserved
522+ if ( isDocSeeded ( room . doc ) ) room . seededObserved = true
523+ if ( originSocketId ( origin ) || ( seededBefore && origin === REDIS_ORIGIN ) ) room . edited = true
524+ // Debounce a persist for LOCAL user edits only (peers debounce their own).
525+ if ( originSocketId ( origin ) ) schedulePersist ( name , room )
512526 } )
513527
514528 awareness . on ( 'update' , ( { added, updated, removed } : AwarenessChange , origin : unknown ) => {
0 commit comments