Skip to content

Commit 0c22829

Browse files
committed
fix(mothership): resync chat caches after an SSE reconnect gap
task_status events are transient and never replayed, so any window with no open connection can drop a create, rename, delete, or completion. The chat hook reconnected silently and reconciled nothing, leaving list and detail caches stale until an unrelated action refreshed them. Resync on reconnect, on the first open of a re-subscription, and on a first open that only succeeded after an error, matching the pattern useMcpToolsEvents already uses for the same gap.
1 parent fb1fead commit 0c22829

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

apps/sim/hooks/use-mothership-chat-events.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ vi.mock('@/lib/browser-agent/transport', () => ({ suspendBrowserScope }))
1414
vi.mock('@/lib/terminal/transport', () => ({ suspendTerminalScope }))
1515

1616
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
17-
import { handleMothershipChatStatusEvent } from '@/hooks/use-mothership-chat-events'
17+
import {
18+
handleMothershipChatStatusEvent,
19+
resyncMothershipChatCaches,
20+
} from '@/hooks/use-mothership-chat-events'
1821

1922
describe('handleMothershipChatStatusEvent', () => {
2023
const queryClient = {
@@ -419,3 +422,25 @@ describe('handleMothershipChatStatusEvent', () => {
419422
expect(queryClient.removeQueries).not.toHaveBeenCalled()
420423
})
421424
})
425+
426+
describe('resyncMothershipChatCaches', () => {
427+
const queryClient = {
428+
invalidateQueries: vi.fn().mockResolvedValue(undefined),
429+
} satisfies Pick<QueryClient, 'invalidateQueries'>
430+
431+
beforeEach(() => {
432+
vi.clearAllMocks()
433+
})
434+
435+
it('invalidates the workspace lists and every chat detail', () => {
436+
resyncMothershipChatCaches(queryClient, 'ws-1')
437+
438+
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2)
439+
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
440+
queryKey: mothershipChatKeys.workspaceLists('ws-1'),
441+
})
442+
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
443+
queryKey: mothershipChatKeys.details(),
444+
})
445+
})
446+
})

apps/sim/hooks/use-mothership-chat-events.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/
99

1010
const logger = createLogger('MothershipChatEvents')
1111

12+
/** Workspaces this process has subscribed to before, so a re-subscribe can be told from a first one. */
13+
const everSubscribed = new Set<string>()
14+
1215
const CHAT_STATUS_TYPES = ['started', 'completed', 'created', 'deleted', 'renamed'] as const
1316
type ChatStatusEventType = (typeof CHAT_STATUS_TYPES)[number]
1417
const CHAT_STATUS_TYPE_SET = new Set<string>(CHAT_STATUS_TYPES)
@@ -128,6 +131,22 @@ export function handleMothershipChatStatusEvent(
128131
}
129132
}
130133

134+
/**
135+
* Re-syncs chat caches after a gap in the event stream.
136+
*
137+
* `task_status` events are transient — nothing replays what was published while
138+
* no connection was open — so any reconnect may have missed a create, rename,
139+
* delete, or completion. Invalidating the workspace lists and every chat detail
140+
* reconciles from the server; only queries that are currently mounted refetch.
141+
*/
142+
export function resyncMothershipChatCaches(
143+
queryClient: Pick<QueryClient, 'invalidateQueries'>,
144+
workspaceId: string
145+
): void {
146+
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.workspaceLists(workspaceId) })
147+
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.details() })
148+
}
149+
131150
/**
132151
* Subscribes to chat status SSE events and invalidates chat caches on changes.
133152
* The SSE event name remains `task_status` for wire compatibility.
@@ -154,7 +173,28 @@ export function useMothershipChatEvents(workspaceId: string | undefined) {
154173
)
155174
})
156175

176+
// `onopen` fires on the initial connect and on every auto-reconnect. Re-sync
177+
// whenever a gap could have swallowed an event: on any reconnect, on the
178+
// first open of a RE-subscription (switching workspace away and back tears
179+
// the connection down, and the list/detail queries remount inside their
180+
// stale times so they do not refetch on their own), and on a first open that
181+
// only succeeded after an error (the initial queries may have failed during
182+
// that gap and will not retry themselves). Skip only a clean first
183+
// subscription — those queries fetch fresh on their own initial mount.
184+
const isResubscribe = everSubscribed.has(workspaceId)
185+
everSubscribed.add(workspaceId)
186+
let opened = false
187+
let erroredBeforeOpen = false
188+
189+
eventSource.onopen = () => {
190+
if (opened || isResubscribe || erroredBeforeOpen) {
191+
resyncMothershipChatCaches(queryClient, workspaceId)
192+
}
193+
opened = true
194+
}
195+
157196
eventSource.onerror = () => {
197+
if (!opened) erroredBeforeOpen = true
158198
logger.warn(`SSE connection error for workspace ${workspaceId}`)
159199
}
160200

0 commit comments

Comments
 (0)