Skip to content

Commit 59a10a7

Browse files
committed
fix(files): elect a single agent-stream writer across tabs
Cursor round 4 (High): with the stream applied client-side, two tabs/windows on the same chat could each derive streamingContent (the reconnect/resume path re-consumes preview events) and each independently insert the stream under a different Yjs clientID, duplicating content until the durable reconcile. Fix — single-writer election via the file-doc awareness (new agent-stream-leader): - a client applying an agent stream announces `agentApplying` on its own awareness - only the leader (min clientID among announcers) applies mid-stream AND at settle; a non-leader renders the leader's ops via Yjs and does not apply (a non-leader applying the final body would re-insert the whole doc as a duplicate) - re-checked each frame, so it converges to one writer the moment awareness propagates; the sub-frame startup race is reconciled by the durable write - single-client (the common case) is unaffected: it is the only announcer, so it always leads
1 parent eb2db57 commit 59a10a7

3 files changed

Lines changed: 126 additions & 7 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { Awareness } from 'y-protocols/awareness'
6+
import * as Y from 'yjs'
7+
import {
8+
announceAgentApplying,
9+
clearAgentApplying,
10+
isAgentStreamLeader,
11+
} from './agent-stream-leader'
12+
13+
/** An Awareness with explicit peer states injected (self, if present, carries no `agentApplying`). */
14+
function awarenessWith(entries: Array<[number, Record<string, unknown>]>): Awareness {
15+
const aw = new Awareness(new Y.Doc())
16+
const states = aw.getStates() as Map<number, Record<string, unknown>>
17+
for (const [clientId, state] of entries) states.set(clientId, state)
18+
return aw
19+
}
20+
21+
describe('agent-stream leader election', () => {
22+
it('a sole announcer is the leader', () => {
23+
expect(isAgentStreamLeader(awarenessWith([[5, { agentApplying: true }]]), 5)).toBe(true)
24+
})
25+
26+
it('the lowest clientID among announcers leads; higher announcers do not', () => {
27+
const aw = awarenessWith([
28+
[7, { agentApplying: true }],
29+
[3, { agentApplying: true }],
30+
[9, { user: { name: 'someone else, not applying' } }],
31+
])
32+
expect(isAgentStreamLeader(aw, 3)).toBe(true)
33+
expect(isAgentStreamLeader(aw, 7)).toBe(false)
34+
})
35+
36+
it('a client that is not announcing is never the leader', () => {
37+
expect(isAgentStreamLeader(awarenessWith([[3, { agentApplying: true }]]), 8)).toBe(false)
38+
})
39+
40+
it('with no announcers, nobody leads', () => {
41+
expect(isAgentStreamLeader(awarenessWith([[3, { user: {} }]]), 3)).toBe(false)
42+
})
43+
44+
it('announce makes self the leader; clear relinquishes it', () => {
45+
const doc = new Y.Doc()
46+
const aw = new Awareness(doc)
47+
announceAgentApplying(aw)
48+
expect(aw.getLocalState()?.agentApplying).toBe(true)
49+
expect(isAgentStreamLeader(aw, doc.clientID)).toBe(true)
50+
51+
clearAgentApplying(aw)
52+
expect(aw.getLocalState()?.agentApplying ?? null).toBeNull()
53+
expect(isAgentStreamLeader(aw, doc.clientID)).toBe(false)
54+
})
55+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Awareness } from 'y-protocols/awareness'
2+
3+
/**
4+
* Awareness field a collaborative client sets on its OWN state while it is applying an agent stream into
5+
* the shared doc. Read by every peer to run the single-writer election below. Coexists with the caret
6+
* `user` field (`setLocalStateField` writes one field without clobbering others).
7+
*/
8+
const AGENT_APPLYING_FIELD = 'agentApplying'
9+
10+
/** Announce that this client is applying an agent stream (candidate in the leader election). */
11+
export function announceAgentApplying(awareness: Awareness): void {
12+
awareness.setLocalStateField(AGENT_APPLYING_FIELD, true)
13+
}
14+
15+
/** Stop announcing (this client is no longer applying an agent stream). */
16+
export function clearAgentApplying(awareness: Awareness): void {
17+
awareness.setLocalStateField(AGENT_APPLYING_FIELD, null)
18+
}
19+
20+
/**
21+
* Single-writer election: exactly one collaborative client applies a given agent stream into the shared
22+
* doc, so N tabs/windows watching the same live copilot stream don't each insert it under a different
23+
* Yjs clientID and duplicate the content. The leader is the MINIMUM clientID among all clients currently
24+
* announcing (via {@link announceAgentApplying}) that they are applying — a deterministic tie-break that
25+
* needs no coordinator. A brief startup race (before an announcement propagates to peers) is bounded to a
26+
* frame or two — self-corrected the moment awareness converges, and reconciled anyway by the durable
27+
* server write. In the common single-client case the caller is the only announcer, so it always leads.
28+
*/
29+
export function isAgentStreamLeader(awareness: Awareness, selfClientId: number): boolean {
30+
let leader = Number.POSITIVE_INFINITY
31+
awareness.getStates().forEach((state, clientId) => {
32+
if ((state as Record<string, unknown> | undefined)?.[AGENT_APPLYING_FIELD] === true) {
33+
leader = Math.min(leader, clientId)
34+
}
35+
})
36+
return leader === selfClientId
37+
}

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import type { SaveStatus } from '@/hooks/use-autosave'
2020
import { useFileContentSource } from '@/hooks/use-file-content-source'
2121
import { PreviewLoadingFrame } from '../preview-shared'
2222
import { useEditableFileContent } from '../use-editable-file-content'
23+
import {
24+
announceAgentApplying,
25+
clearAgentApplying,
26+
isAgentStreamLeader,
27+
} from './collaboration/agent-stream-leader'
2328
import {
2429
type AgentStreamSession,
2530
applyAgentStreamFrame,
@@ -874,8 +879,12 @@ export function LoadedRichMarkdownEditor({
874879
if (!collabReady) return
875880
// Open the stream's shadow on the FIRST ready frame so it captures the pre-stream base (immune to
876881
// later peer edits) — including for an `update`, whose frames are all held until settle, so settle
877-
// still has a shadow through which to apply the final rewrite.
878-
agentStreamSessionRef.current ??= beginAgentStream(editor)
882+
// still has a shadow through which to apply the final rewrite. Announce candidacy in the
883+
// single-writer election so only one tab/window actually applies this stream (see the tick).
884+
if (agentStreamSessionRef.current === null) {
885+
agentStreamSessionRef.current = beginAgentStream(editor)
886+
if (collaboration) announceAgentApplying(collaboration.awareness)
887+
}
879888
const session = agentStreamSessionRef.current
880889
const body = splitFrontmatter(content).body
881890
if (body === lastStreamedBodyRef.current) return
@@ -895,6 +904,17 @@ export function LoadedRichMarkdownEditor({
895904
streamRafRef.current = null
896905
return
897906
}
907+
// Single-writer election: only the leader (min clientID among clients applying this stream)
908+
// writes it into the shared doc, so multiple tabs/windows watching the same live copilot stream
909+
// don't each insert it and duplicate content. A non-leader renders the leader's ops via Yjs;
910+
// re-checked each frame, so it converges to one writer the moment awareness propagates.
911+
if (
912+
collaboration &&
913+
!isAgentStreamLeader(collaboration.awareness, collaboration.doc.clientID)
914+
) {
915+
streamRafRef.current = null
916+
return
917+
}
898918
if (
899919
pending.length > STREAM_REPARSE_THROTTLE_THRESHOLD &&
900920
performance.now() - lastStreamParseAtRef.current < STREAM_REPARSE_THROTTLE_MS
@@ -928,14 +948,21 @@ export function LoadedRichMarkdownEditor({
928948
// survive); otherwise open one on demand. The durable server write then lands as a noop diff.
929949
if (wasStreamingRef.current && collabReady) {
930950
wasStreamingRef.current = false
951+
// Only the elected leader applies the final body — a non-leader never applied mid-stream, so
952+
// reconciling its base-seeded shadow to the final body would re-insert the whole doc as a
953+
// duplicate; it converges to the final state via Yjs + the durable server write instead. Compute
954+
// leadership BEFORE clearing our announcement, then stop announcing.
955+
const wasLeader =
956+
!collaboration || isAgentStreamLeader(collaboration.awareness, collaboration.doc.clientID)
957+
if (collaboration) clearAgentApplying(collaboration.awareness)
958+
lastStreamedBodyRef.current = null
931959
const finalBody = splitFrontmatter(content).body
932-
const session = agentStreamSessionRef.current ?? beginAgentStream(editor)
960+
const session = wasLeader
961+
? (agentStreamSessionRef.current ?? beginAgentStream(editor))
962+
: agentStreamSessionRef.current
933963
agentStreamSessionRef.current = null
934-
lastStreamedBodyRef.current = null
935964
if (session) {
936-
runOffRender(() => {
937-
applyAgentStreamFrame(editor, session, finalBody)
938-
})
965+
if (wasLeader) runOffRender(() => applyAgentStreamFrame(editor, session, finalBody))
939966
// Free the shadow with an UNGUARDED microtask (not `runOffRender`): a rapid follow-up stream
940967
// can supersede the run token and drop the apply above, but the shadow must always be
941968
// destroyed. Queued after the apply, so it frees the shadow only once that has had its chance.

0 commit comments

Comments
 (0)