fix(queue): reconcile missed delivery across live transports (#3440)
Deliver queue updates on the control SSE stream, recover independently of bootstrap suppression, and preserve authoritative empty snapshots against delayed responses. Coalesce hydration and recovery while retaining unfinished legacy migration across runtime switches. Workspace type-check, lint, tests and build passed. Follow-up recovery and migration fixes pass 27 queue tests, 3 control-stream tests and UI type-check.
This commit is contained in:
@@ -81,4 +81,44 @@ describe('openchamber events', () => {
|
||||
]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('a connected control SSE stream clears delivered queues without reconnecting or polling', async () => {
|
||||
const { subscribeMessageQueueSync } = await import('@/sync/message-queue-sync');
|
||||
const { getRuntimeKey } = await import('./runtime-switch');
|
||||
const { useMessageQueueStore, createMessageQueueTarget, getMessageQueueKey } = await import('@/stores/messageQueueStore');
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const target = createMessageQueueTarget('session-sse', '/repo', runtimeKey);
|
||||
if (!target) throw new Error('Missing queue target');
|
||||
useMessageQueueStore.getState().resetForRuntimeSwitch(runtimeKey);
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, sendingIds: {} });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let reads = 0;
|
||||
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), 'http://runtime.test');
|
||||
if (url.pathname === '/api/message-queue') reads += 1;
|
||||
return Response.json({ revision: 1, sessions: [] });
|
||||
}, originalFetch);
|
||||
const unsubscribe = subscribeMessageQueueSync(runtimeKey);
|
||||
const source = MockEventSource.instances[0];
|
||||
try {
|
||||
source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:event-stream-ready', properties: {} }) });
|
||||
await useMessageQueueStore.getState().hydrate();
|
||||
expect(reads).toBe(1);
|
||||
const session = { sessionId: target.sessionId, directory: target.directory, sendingId: 'q1', items: [{ id: 'q1', content: 'queued', text: 'queued', createdAt: 1, attachments: [], sendConfig: { providerID: 'p', modelID: 'm' } }] };
|
||||
source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:message-queue.updated', properties: { revision: 2, session } }) });
|
||||
const key = getMessageQueueKey(target);
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1);
|
||||
source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:message-queue.updated', properties: { revision: 3, session: { ...session, items: [], sendingId: null } } }) });
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined();
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toBeUndefined();
|
||||
expect(reads).toBe(1);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
unsubscribe();
|
||||
source.onmessage?.({ data: JSON.stringify({ type: 'openchamber:message-queue.updated', properties: { revision: 4, session } }) });
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { subscribeRuntimeEndpointChanged } from './runtime-switch';
|
||||
import { isVSCodeRuntime } from './desktop';
|
||||
import { messageQueueUpdatedEventSchema, type MessageQueueUpdatedEvent } from '@/stores/messageQueueStore';
|
||||
|
||||
type ScheduledTaskRanEvent = {
|
||||
type: 'scheduled-task-ran';
|
||||
@@ -44,6 +45,8 @@ type AgentMemoryChangedEvent = {
|
||||
};
|
||||
|
||||
type OpenChamberEvent =
|
||||
| { type: 'event-stream-ready' }
|
||||
| MessageQueueUpdatedEvent
|
||||
| ScheduledTaskRanEvent
|
||||
| SessionCreatedEvent
|
||||
| BrowserControlRequestEvent
|
||||
@@ -127,6 +130,15 @@ const getEventProperties = (properties: unknown): Record<string, unknown> | null
|
||||
const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) => {
|
||||
if (envelope.type === 'openchamber:event-stream-ready') {
|
||||
reconnectAttempt = 0;
|
||||
for (const listener of listeners) listener({ type: 'event-stream-ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.type === 'openchamber:message-queue.updated') {
|
||||
const parsed = messageQueueUpdatedEventSchema.safeParse(envelope);
|
||||
if (parsed.success) {
|
||||
for (const listener of listeners) listener(parsed.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,9 +263,11 @@ const connect = () => {
|
||||
canControlBrowser ? { browser: '1' } : undefined,
|
||||
));
|
||||
source.onopen = () => {
|
||||
if (eventSource !== source) return;
|
||||
resetHeartbeatTimer();
|
||||
};
|
||||
source.onmessage = (event) => {
|
||||
if (eventSource !== source) return;
|
||||
resetHeartbeatTimer();
|
||||
const envelope = parseEnvelope(event.data);
|
||||
if (!envelope) {
|
||||
@@ -263,6 +277,7 @@ const connect = () => {
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
if (eventSource !== source) return;
|
||||
cleanupSource();
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
@@ -93,7 +93,27 @@ These stores coordinate persistent project/session metadata across multiple view
|
||||
|
||||
`useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn.
|
||||
|
||||
`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A per-key server revision rejects stale snapshots. An empty session that arrives without a directory (servers before 1.22.2 dropped it once the queue emptied) clears every projection of that session id in the runtime, because a session id is unique across directories. Projection items carry attachment metadata only and no captured context; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async.
|
||||
`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`.
|
||||
On web, desktop, and mobile the server delivers the queue independently of the
|
||||
UI. The store projects authoritative snapshots and revisioned session updates.
|
||||
`sync/message-queue-sync.ts` receives queue events through the shared control SSE
|
||||
stream at `/api/openchamber/events`, including while OpenCode uses SSE fallback.
|
||||
It adds no poller or per-session connection. Either stream reconnecting requests
|
||||
`resync()`, independently of directory-bootstrap suppression.
|
||||
|
||||
Hydration and recovery share one in-flight request per runtime. A recovery edge
|
||||
during its snapshot read earns one trailing read; legacy uploads are attempted
|
||||
once per runtime rather than repeated on reconnect or snapshot failure. Snapshot
|
||||
reads have a 15-second deadline. Failure preserves the projection and runtime
|
||||
switches reject stale completions. Full-snapshot revisions also cover omitted
|
||||
sessions, so a delayed mutation response cannot resurrect a cleared queue;
|
||||
session events newer than that snapshot survive reconciliation.
|
||||
|
||||
Mutations are optimistic and then settled on the server's copy; failed
|
||||
round-trips re-read instead of guessing. Empty legacy events without a directory
|
||||
clear all projections of their session in that runtime. Projection items carry
|
||||
attachment metadata only, so `popToInput()` and `takeForSend()` asynchronously
|
||||
remove the message on the server and retrieve its complete captured payload.
|
||||
|
||||
A queued message is captured whole, so whoever delivers it sends exactly what the composer would have: `text` (the content with its agent mention stripped and `@file` mentions already resolved into `attachments`), `agentMention`, and `context` — every chip the composer had attached (inline comments, terminal selections, browser annotations, PR comments/checks, quotes, linked issue/PR/Linear references, pending synthetic parts) plus the skill instruction derived from the text. `QueuedContextPart` distinguishes attached items (restored to the chips when the message is edited) from derived instructions (re-derived on send, never restored) and from synthetic parts other surfaces handed the composer (restored as pending). Context is captured by `buildComposerContext` and delivered by `queuedContextToParts` (`components/chat/composer/submit/buildOutgoingMessage.ts`), the same functions the composer uses for its own send. Nothing is re-resolved at delivery: the server has no agent list, no confirmed mentions, and no draft store. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { MessageQueueUpdatedEvent } from "./messageQueueStore"
|
||||
type FetchCall = { path: string; method: string; body: ReturnType<typeof JSON.parse> }
|
||||
let calls: FetchCall[] = []
|
||||
let activeRuntimeKey = "runtime-a"
|
||||
let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 })
|
||||
let respond: (call: FetchCall) => Response | Promise<Response> = () => new Response("{}", { status: 200 })
|
||||
|
||||
mock.module("@/lib/runtime-fetch", () => ({
|
||||
runtimeFetch: async (path: string, init?: RequestInit) => {
|
||||
@@ -44,6 +44,15 @@ type ServerReply = {
|
||||
|
||||
const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
|
||||
const deferredResponse = () => {
|
||||
let complete: ((response: Response) => void) | undefined
|
||||
const promise = new Promise<Response>((resolve) => { complete = resolve })
|
||||
return { promise, resolve: (response: Response) => {
|
||||
if (!complete) throw new Error("Deferred response was not initialized")
|
||||
complete(response)
|
||||
} }
|
||||
}
|
||||
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
const key = getMessageQueueKey(target)
|
||||
|
||||
@@ -82,6 +91,7 @@ const attachment: AttachedFile = {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey)
|
||||
activeRuntimeKey = "runtime-a"
|
||||
useInputHistoryStore.setState({ globalBuckets: {}, sessionBuckets: {} })
|
||||
calls = []
|
||||
@@ -123,6 +133,128 @@ describe("server-owned message queue", () => {
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("hydrate keeps a queue newer than its snapshot", async () => {
|
||||
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued after the read started")])), "runtime-a")
|
||||
respond = () => json({ revision: 9, sessions: [] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("resync can establish the initial snapshot before bootstrap", async () => {
|
||||
activeRuntimeKey = "runtime-never-hydrated"
|
||||
respond = () => json({ revision: 1, sessions: [] })
|
||||
await useMessageQueueStore.getState().resync()
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("a reconnect during the initial snapshot retains one trailing refresh", async () => {
|
||||
const first = deferredResponse()
|
||||
respond = () => calls.length === 1 ? first.promise : json({ revision: 12, sessions: [] })
|
||||
const bootstrap = useMessageQueueStore.getState().hydrate()
|
||||
const reconnect = useMessageQueueStore.getState().resync()
|
||||
const secondReconnect = useMessageQueueStore.getState().resync()
|
||||
expect(calls).toHaveLength(1)
|
||||
first.resolve(json({ revision: 10, sessions: [session([serverItem("q1", "delivered after snapshot")])] }))
|
||||
await Promise.all([bootstrap, reconnect, secondReconnect])
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("concurrent bootstrap and recovery migrate a legacy message only once", async () => {
|
||||
activeRuntimeKey = "runtime-legacy-recovery"
|
||||
const legacyTarget = createMessageQueueTarget("session-1", "/repo", activeRuntimeKey)
|
||||
if (!legacyTarget) throw new Error("Missing test target")
|
||||
const legacyKey = getMessageQueueKey(legacyTarget)
|
||||
useMessageQueueStore.setState({ queuedMessages: { [legacyKey]: [{ id: "local", content: "legacy", text: "legacy", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }] } })
|
||||
const upload = deferredResponse()
|
||||
respond = (call) => call.method === "POST" ? upload.promise : json({ revision: 2, sessions: [] })
|
||||
const bootstrap = useMessageQueueStore.getState().hydrate()
|
||||
const recovery = useMessageQueueStore.getState().resync()
|
||||
upload.resolve(json({ revision: 2, session: session([]) }))
|
||||
await Promise.all([bootstrap, recovery])
|
||||
expect(calls.filter((call) => call.method === "POST")).toHaveLength(1)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[legacyKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("an empty snapshot prevents delayed responses from resurrecting omitted queues", async () => {
|
||||
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued")], "q1")), "runtime-a")
|
||||
respond = () => json({ revision: 12, sessions: [] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
applyMessageQueueUpdatedEvent(updated(11, session([serverItem("q1", "stale")], "q1")), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toBeUndefined()
|
||||
const other = { ...session([serverItem("q2", "unseen stale")]), sessionId: "unseen" }
|
||||
applyMessageQueueUpdatedEvent(updated(11, other), "runtime-a")
|
||||
expect(Object.keys(useMessageQueueStore.getState().queuedMessages)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("recovery demand survives a failed in-flight snapshot", async () => {
|
||||
const first = deferredResponse()
|
||||
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "delivered")])), "runtime-a")
|
||||
respond = () => calls.length === 1 ? first.promise : json({ revision: 12, sessions: [] })
|
||||
const bootstrap = useMessageQueueStore.getState().hydrate()
|
||||
const recovery = useMessageQueueStore.getState().resync()
|
||||
first.resolve(new Response(null, { status: 503 }))
|
||||
await Promise.all([bootstrap, recovery])
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returning to a runtime migrates its unattempted legacy messages without repeating the first upload", async () => {
|
||||
activeRuntimeKey = "runtime-partial-migration"
|
||||
const legacyTarget = createMessageQueueTarget("session-1", "/repo", activeRuntimeKey)
|
||||
if (!legacyTarget) throw new Error("Missing test target")
|
||||
const legacyKey = getMessageQueueKey(legacyTarget)
|
||||
useMessageQueueStore.setState({ queuedMessages: { [legacyKey]: ["first", "second"].map((id) => ({ id, content: id, text: id, createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } })) } })
|
||||
const first = deferredResponse()
|
||||
respond = (call) => call.method === "POST"
|
||||
? calls.length === 1 ? first.promise : json({ revision: 2, session: session([]) })
|
||||
: json({ revision: 3, sessions: [] })
|
||||
const initial = useMessageQueueStore.getState().hydrate()
|
||||
useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey)
|
||||
activeRuntimeKey = "runtime-other"
|
||||
first.resolve(json({ revision: 1, session: session([]) }))
|
||||
await initial
|
||||
activeRuntimeKey = "runtime-partial-migration"
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
expect(calls.filter((call) => call.method === "POST").map((call) => call.body.item.content)).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("a failed refresh preserves the projection and a later recovery retries", async () => {
|
||||
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued")])), "runtime-a")
|
||||
respond = () => new Response(null, { status: 503 })
|
||||
await expect(useMessageQueueStore.getState().resync()).rejects.toThrow()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
||||
respond = () => json({ revision: 12, sessions: [] })
|
||||
await useMessageQueueStore.getState().resync()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a runtime switch rejects an old snapshot and its pending recovery", async () => {
|
||||
const old = deferredResponse()
|
||||
respond = () => old.promise
|
||||
const bootstrap = useMessageQueueStore.getState().hydrate()
|
||||
const recovery = useMessageQueueStore.getState().resync()
|
||||
useMessageQueueStore.getState().resetForRuntimeSwitch(activeRuntimeKey)
|
||||
activeRuntimeKey = "runtime-b"
|
||||
respond = () => json({ revision: 1, sessions: [] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
old.resolve(json({ revision: 99, sessions: [session([serverItem("q1", "old runtime")])] }))
|
||||
await Promise.all([bootstrap, recovery])
|
||||
expect(Object.keys(useMessageQueueStore.getState().queuedMessages)).toHaveLength(0)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("resync drops a queue the server no longer lists", async () => {
|
||||
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "queued")], "q1")] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
respond = () => json({ revision: 4, sessions: [] })
|
||||
await useMessageQueueStore.getState().resync()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("addToQueue shows the message at once and settles on the server's copy", async () => {
|
||||
respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) })
|
||||
const pending = useMessageQueueStore.getState().addToQueue(target, {
|
||||
@@ -270,6 +402,16 @@ describe("server-owned message queue", () => {
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("a failed take re-reads the server so a stale projection is cleared", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "already delivered", text: "already delivered", createdAt: 1 }] } })
|
||||
respond = (call) => (call.path.endsWith("/take")
|
||||
? new Response(JSON.stringify({ error: "queued message not found" }), { status: 404 })
|
||||
: json({ revision: 12, sessions: [] }))
|
||||
await expect(useMessageQueueStore.getState().takeForSend(target, "q1")).rejects.toThrow()
|
||||
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("broadcasts update the projection but never move it backwards", () => {
|
||||
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
|
||||
@@ -328,9 +328,16 @@ const sessionPath = (sessionId: string) => `/api/message-queue/sessions/${encode
|
||||
* stale local copy would resurrect messages the server already delivered.
|
||||
*/
|
||||
const serverOwnedRuntimeKeys = new Set<string>();
|
||||
type LegacyQueueMigration = {
|
||||
items: Array<{ target: MessageQueueTarget; message: QueuedMessage }>;
|
||||
pending: Promise<void> | null;
|
||||
};
|
||||
const legacyMigrations = new Map<string, LegacyQueueMigration>();
|
||||
|
||||
/** Server revision last applied per queue key; older snapshots are ignored. */
|
||||
const appliedRevisions = new Map<string, number>();
|
||||
/** A full snapshot also owns sessions it omits, including previously unseen keys. */
|
||||
const snapshotRevisions = new Map<string, number>();
|
||||
let hydrationGeneration = 0;
|
||||
|
||||
interface MessageQueueState {
|
||||
@@ -375,6 +382,8 @@ interface MessageQueueActions {
|
||||
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
/** Server-owned queue: load the authoritative queue for the active runtime. */
|
||||
hydrate: () => Promise<void>;
|
||||
/** Server-owned queue: re-read after an event-stream gap. */
|
||||
resync: () => Promise<void>;
|
||||
/** Server-owned queue: apply one session's authoritative state (broadcast or response). */
|
||||
applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void;
|
||||
/** Server-owned queue: tell the server to hold or release a session's delivery. */
|
||||
@@ -454,8 +463,11 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => {
|
||||
let hydration: { runtimeKey: string; promise: Promise<void> } | null = null;
|
||||
let resyncRequested = false;
|
||||
const applyServerSession = (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => {
|
||||
if (expectedRuntimeKey !== getRuntimeKey()) return;
|
||||
if ((snapshotRevisions.get(expectedRuntimeKey) ?? -1) > revision) return;
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, expectedRuntimeKey);
|
||||
if (!target) {
|
||||
// Servers before 1.22.2 drop a session's directory once its
|
||||
@@ -609,18 +621,23 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
takeForSend: async (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
if (isServerOwnedMessageQueue()) {
|
||||
if (messageId) {
|
||||
const result = await requestJson(
|
||||
serverTakeResponseSchema,
|
||||
`${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`,
|
||||
jsonInit('POST'),
|
||||
);
|
||||
try {
|
||||
if (messageId) {
|
||||
const result = await requestJson(
|
||||
serverTakeResponseSchema,
|
||||
`${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`,
|
||||
jsonInit('POST'),
|
||||
);
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return [toQueuedMessage(result.item)];
|
||||
}
|
||||
const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST'));
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return [toQueuedMessage(result.item)];
|
||||
return result.items.map(toQueuedMessage);
|
||||
} catch (error) {
|
||||
await refreshSession(target);
|
||||
throw error;
|
||||
}
|
||||
const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST'));
|
||||
applyServerSession(result.session, result.revision, target.runtimeKey);
|
||||
return result.items.map(toQueuedMessage);
|
||||
}
|
||||
|
||||
const state = get();
|
||||
@@ -707,63 +724,98 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
hydrate: () => {
|
||||
if (!isServerOwnedMessageQueue()) return Promise.resolve();
|
||||
const runtimeKey = getRuntimeKey();
|
||||
if (hydration?.runtimeKey === runtimeKey) return hydration.promise;
|
||||
const generation = ++hydrationGeneration;
|
||||
const isCurrent = () => generation === hydrationGeneration && runtimeKey === getRuntimeKey();
|
||||
|
||||
// Messages queued by an older build live in this browser only.
|
||||
// Hand them to the server once so they are still delivered;
|
||||
// whatever cannot be uploaded is superseded by the server's queue.
|
||||
const legacyEntries = Object.entries(get().queuedMessages)
|
||||
.map(([key, queue]) => ({ target: parseMessageQueueKey(key), queue }))
|
||||
.filter((entry): entry is { target: MessageQueueTarget; queue: QueuedMessage[] } => (
|
||||
entry.target !== null && entry.target.runtimeKey === runtimeKey && !serverOwnedRuntimeKeys.has(runtimeKey)
|
||||
));
|
||||
for (const { target, queue } of legacyEntries) {
|
||||
for (const message of queue) {
|
||||
if (!message.sendConfig) continue;
|
||||
try {
|
||||
await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', {
|
||||
directory: target.directory,
|
||||
item: toServerItemInput(message, message.sendConfig),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to migrate a locally queued message to the server:', error);
|
||||
}
|
||||
const promise = (async () => {
|
||||
// Migration and recovery share one request owner so
|
||||
// reconnects cannot upload a legacy message twice.
|
||||
let migration = legacyMigrations.get(runtimeKey);
|
||||
if (!migration) {
|
||||
const items = Object.entries(get().queuedMessages).flatMap(([key, queue]) => {
|
||||
const target = parseMessageQueueKey(key);
|
||||
if (!target || target.runtimeKey !== runtimeKey) return [];
|
||||
return queue.map((message) => ({ target, message }));
|
||||
});
|
||||
migration = { items, pending: null };
|
||||
legacyMigrations.set(runtimeKey, migration);
|
||||
}
|
||||
while (migration.pending || migration.items.length > 0) {
|
||||
if (!isCurrent()) return;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue');
|
||||
if (!isCurrent()) return;
|
||||
serverOwnedRuntimeKeys.add(runtimeKey);
|
||||
set((state) => {
|
||||
const queuedMessages: Record<string, QueuedMessage[]> = {};
|
||||
const sendingIds: Record<string, string[]> = {};
|
||||
for (const [key, queue] of Object.entries(state.queuedMessages)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) queuedMessages[key] = queue;
|
||||
}
|
||||
for (const [key, ids] of Object.entries(state.sendingIds)) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) sendingIds[key] = ids;
|
||||
}
|
||||
for (const session of snapshot.sessions) {
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey);
|
||||
if (!target) continue;
|
||||
const key = getMessageQueueKey(target);
|
||||
if ((appliedRevisions.get(key) ?? -1) > snapshot.revision) {
|
||||
// A broadcast newer than this snapshot already landed; keep it.
|
||||
if (state.queuedMessages[key]) queuedMessages[key] = state.queuedMessages[key];
|
||||
if (state.sendingIds[key]) sendingIds[key] = state.sendingIds[key];
|
||||
if (migration.pending) {
|
||||
await migration.pending;
|
||||
continue;
|
||||
}
|
||||
appliedRevisions.set(key, snapshot.revision);
|
||||
if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage);
|
||||
if (session.sendingId) sendingIds[key] = [session.sendingId];
|
||||
const next = migration.items.shift();
|
||||
if (!next?.message.sendConfig) continue;
|
||||
const { target, message } = next;
|
||||
const upload = requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', {
|
||||
directory: target.directory,
|
||||
item: toServerItemInput(message, next.message.sendConfig),
|
||||
})).then(() => undefined).catch((error) => {
|
||||
console.warn('[queue] failed to migrate a locally queued message to the server:', error);
|
||||
});
|
||||
migration.pending = upload;
|
||||
const owner = migration;
|
||||
void upload.then(() => { if (owner.pending === upload) owner.pending = null; });
|
||||
await upload;
|
||||
}
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
|
||||
do {
|
||||
resyncRequested = false;
|
||||
let snapshot: z.infer<typeof serverSnapshotSchema>;
|
||||
try {
|
||||
snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue', { signal: AbortSignal.timeout(15_000) });
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
if (resyncRequested) continue;
|
||||
throw error;
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
serverOwnedRuntimeKeys.add(runtimeKey);
|
||||
if ((snapshotRevisions.get(runtimeKey) ?? -1) > snapshot.revision) continue;
|
||||
snapshotRevisions.set(runtimeKey, snapshot.revision);
|
||||
set((state) => {
|
||||
// A broadcast newer than this snapshot wins, listed in it or not.
|
||||
const isNewerThanSnapshot = (key: string) => (appliedRevisions.get(key) ?? -1) > snapshot.revision;
|
||||
const keep = (key: string) => parseMessageQueueKey(key)?.runtimeKey !== runtimeKey || isNewerThanSnapshot(key);
|
||||
const queuedMessages: Record<string, QueuedMessage[]> = {};
|
||||
const sendingIds: Record<string, string[]> = {};
|
||||
for (const [key, queue] of Object.entries(state.queuedMessages)) {
|
||||
if (keep(key)) queuedMessages[key] = queue;
|
||||
}
|
||||
for (const [key, ids] of Object.entries(state.sendingIds)) {
|
||||
if (keep(key)) sendingIds[key] = ids;
|
||||
}
|
||||
for (const session of snapshot.sessions) {
|
||||
const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey);
|
||||
if (!target) continue;
|
||||
const key = getMessageQueueKey(target);
|
||||
if (isNewerThanSnapshot(key)) continue;
|
||||
appliedRevisions.set(key, snapshot.revision);
|
||||
if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage);
|
||||
if (session.sendingId) sendingIds[key] = [session.sendingId];
|
||||
}
|
||||
return { queuedMessages, sendingIds };
|
||||
});
|
||||
} while (resyncRequested && isCurrent());
|
||||
})();
|
||||
const run = { runtimeKey, promise };
|
||||
hydration = run;
|
||||
const release = () => { if (hydration === run) hydration = null; };
|
||||
void promise.then(release, release);
|
||||
return promise;
|
||||
},
|
||||
|
||||
resync: () => {
|
||||
// Share legacy migration with bootstrap. A recovery edge
|
||||
// during its snapshot read still earns one trailing read.
|
||||
if (hydration?.runtimeKey === getRuntimeKey()) resyncRequested = true;
|
||||
return get().hydrate();
|
||||
},
|
||||
|
||||
applyServerSession,
|
||||
@@ -776,6 +828,14 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
|
||||
resetForRuntimeSwitch: (previousRuntimeKey) => {
|
||||
hydrationGeneration += 1;
|
||||
hydration = null;
|
||||
resyncRequested = false;
|
||||
if (previousRuntimeKey) {
|
||||
snapshotRevisions.delete(previousRuntimeKey);
|
||||
for (const key of appliedRevisions.keys()) {
|
||||
if (parseMessageQueueKey(key)?.runtimeKey === previousRuntimeKey) appliedRevisions.delete(key);
|
||||
}
|
||||
}
|
||||
if (!previousRuntimeKey || !serverOwnedRuntimeKeys.has(previousRuntimeKey)) return;
|
||||
// The previous runtime's projection belongs to its server;
|
||||
// switching back re-hydrates it from there.
|
||||
@@ -817,19 +877,17 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
)
|
||||
);
|
||||
|
||||
const serverUpdatedEventSchema = z.object({
|
||||
export const messageQueueUpdatedEventSchema = z.object({
|
||||
type: z.literal('openchamber:message-queue.updated'),
|
||||
properties: z.object({ revision: z.number(), session: serverSessionSchema }),
|
||||
});
|
||||
|
||||
export type MessageQueueUpdatedEvent = {
|
||||
type: 'openchamber:message-queue.updated';
|
||||
properties: z.infer<typeof serverUpdatedEventSchema>['properties'];
|
||||
};
|
||||
export type MessageQueueUpdatedEvent = z.infer<typeof messageQueueUpdatedEventSchema>;
|
||||
|
||||
/** `openchamber:message-queue.updated` broadcast → projection. */
|
||||
export const applyMessageQueueUpdatedEvent = (payload: Event | MessageQueueUpdatedEvent, expectedRuntimeKey: string): void => {
|
||||
if (!isServerOwnedMessageQueue()) return;
|
||||
const parsed = serverUpdatedEventSchema.safeParse(payload);
|
||||
const parsed = messageQueueUpdatedEventSchema.safeParse(payload);
|
||||
if (!parsed.success) return;
|
||||
const { session, revision } = parsed.data.properties;
|
||||
useMessageQueueStore.getState().applyServerSession(session, revision, expectedRuntimeKey);
|
||||
|
||||
@@ -423,6 +423,12 @@ During streaming, `message.part.delta` fires ~60 times/sec. Eagerly cloning all
|
||||
|
||||
## Event → field mapping
|
||||
|
||||
Queue recovery is independent of the directory-bootstrap debounce. The sync
|
||||
provider subscribes to `message-queue-sync.ts` for control-stream updates and
|
||||
requests a queue refresh on every main-stream connection or transport switch,
|
||||
including the first connection. The queue store coalesces these requests with
|
||||
bootstrap and owns snapshot ordering and legacy-upload lifetime.
|
||||
|
||||
Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
|
||||
|
||||
| Event type | Fields to clone |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
|
||||
/** Queue events use the control SSE stream even while OpenCode uses WS. */
|
||||
export const subscribeMessageQueueSync = (runtimeKey: string): (() => void) => (
|
||||
subscribeOpenchamberEvents((event) => {
|
||||
if (runtimeKey !== getRuntimeKey()) return;
|
||||
if (event.type === 'event-stream-ready') {
|
||||
void useMessageQueueStore.getState().resync().catch(() => undefined);
|
||||
} else if (event.type === 'openchamber:message-queue.updated') {
|
||||
applyMessageQueueUpdatedEvent(event, runtimeKey);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -49,6 +49,7 @@ import { messagesBefore } from "./message-ordering"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { usePermissionStore } from "@/stores/permissionStore"
|
||||
import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from "@/stores/messageQueueStore"
|
||||
import { subscribeMessageQueueSync } from "./message-queue-sync"
|
||||
import {
|
||||
processVSCodePermissionAutoAccept,
|
||||
processVSCodeReconciledPermissionAutoAccept,
|
||||
@@ -2508,6 +2509,10 @@ export function SyncProvider(props: {
|
||||
// Event pipeline — created once per mount. No class, no start/stop.
|
||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||
useEffect(() => {
|
||||
const unsubscribeQueueEvents = subscribeMessageQueueSync(runtimeKey)
|
||||
const resyncAfterStreamGap = (reason: SessionMaterializationReason) => {
|
||||
for (const dir of childStores.children.keys()) triggerDirectoryResync(dir, reason)
|
||||
}
|
||||
const pipeline = createEventPipeline({
|
||||
sdk: props.sdk,
|
||||
transport: messageStreamTransport,
|
||||
@@ -2541,6 +2546,8 @@ export function SyncProvider(props: {
|
||||
}
|
||||
},
|
||||
onReconnect: () => {
|
||||
// Queue recovery is independent of the directory-bootstrap debounce.
|
||||
void useMessageQueueStore.getState().resync().catch(() => undefined)
|
||||
useConfigStore.setState({
|
||||
isConnected: true,
|
||||
hasEverConnected: true,
|
||||
@@ -2554,9 +2561,7 @@ export function SyncProvider(props: {
|
||||
if (isRecentBoot()) {
|
||||
return
|
||||
}
|
||||
for (const dir of childStores.children.keys()) {
|
||||
triggerDirectoryResync(dir, "stream-reconnect")
|
||||
}
|
||||
resyncAfterStreamGap("stream-reconnect")
|
||||
},
|
||||
onDisconnect: (reason) => {
|
||||
if (!pipelineHasConnectedRef.current) {
|
||||
@@ -2570,6 +2575,7 @@ export function SyncProvider(props: {
|
||||
})
|
||||
},
|
||||
onTransportSwitch: () => {
|
||||
void useMessageQueueStore.getState().resync().catch(() => undefined)
|
||||
// Transport changes are gap-prone in real networks. Treat them like a
|
||||
// reconnect and refresh active session snapshots from HTTP.
|
||||
useConfigStore.setState({
|
||||
@@ -2577,9 +2583,7 @@ export function SyncProvider(props: {
|
||||
hasEverConnected: true,
|
||||
connectionPhase: "connected",
|
||||
})
|
||||
for (const dir of childStores.children.keys()) {
|
||||
triggerDirectoryResync(dir, "transport-switch")
|
||||
}
|
||||
resyncAfterStreamGap("transport-switch")
|
||||
},
|
||||
})
|
||||
pipelineReconnectRef.current = pipeline.reconnect
|
||||
@@ -2588,6 +2592,7 @@ export function SyncProvider(props: {
|
||||
pipelineReconnectRef.current = null
|
||||
}
|
||||
pipeline.cleanup()
|
||||
unsubscribeQueueEvents()
|
||||
}
|
||||
}, [props.sdk, childStores, routingIndex, messageStreamTransport, runtimeKey, triggerDirectoryResync])
|
||||
|
||||
|
||||
@@ -913,7 +913,13 @@ const messageQueueRuntime = createMessageQueueRuntime({
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
sessionKnowledgeRuntime,
|
||||
broadcastGlobalUiEvent,
|
||||
// OpenCode's /global/event SSE proxy cannot carry OpenChamber-owned events.
|
||||
// Use the shared control stream for SSE clients and the existing WS fan-out.
|
||||
broadcastGlobalUiEvent: createGlobalUiEventBroadcaster({
|
||||
sseClients: uiOpenChamberEventClients,
|
||||
wsClients: uiNotificationWsClients,
|
||||
writeSseEvent,
|
||||
}),
|
||||
onPromptSent: (sessionId) => sessionRuntime.markUserMessageSent(sessionId),
|
||||
dataDir: OPENCHAMBER_DATA_DIR,
|
||||
});
|
||||
|
||||
@@ -140,7 +140,10 @@ allowlists.
|
||||
|
||||
Every mutation broadcasts `openchamber:message-queue.updated` with
|
||||
`{ revision, session }` to all connected clients (SSE and WS), so several
|
||||
devices on one server see one queue. The session in that payload always names
|
||||
devices on one server see one queue. SSE uses the shared control stream at
|
||||
`/api/openchamber/events`; `/api/global/event` carries no OpenChamber events.
|
||||
The UI subscribes independently of its OpenCode transport and re-reads the
|
||||
snapshot whenever either stream reconnects. The session in that payload always names
|
||||
its `directory`, including the broadcast that removes the last item: the UI
|
||||
keys its projection by directory, and a broadcast without one left the
|
||||
delivered message on screen (a session's directory is remembered until the
|
||||
|
||||
Reference in New Issue
Block a user