diff --git a/src/gateway/server-session-events.test.ts b/src/gateway/server-session-events.test.ts index 9f11e3b68390..157b3f99b391 100644 --- a/src/gateway/server-session-events.test.ts +++ b/src/gateway/server-session-events.test.ts @@ -14,6 +14,7 @@ const sessionRow = vi.hoisted(() => ({ agentRuntime: { id: "openclaw", source: "model" }, })); const isEmbeddedAgentRunInProgressMock = vi.hoisted(() => vi.fn()); +const loadGatewaySessionRowMock = vi.hoisted(() => vi.fn()); const projectChatDisplayMessageMock = vi.hoisted(() => vi.fn((message: unknown) => message)); const loadAccessorSessionEntryReadOnlyMock = vi.hoisted(() => vi.fn()); const loadGatewaySessionEntryReadOnlyMock = vi.hoisted(() => vi.fn()); @@ -32,7 +33,7 @@ vi.mock("./chat-display-projection.js", () => ({ })); vi.mock("./session-utils.js", () => ({ attachOpenClawTranscriptMeta: (message: unknown) => message, - loadGatewaySessionRow: () => sessionRow, + loadGatewaySessionRow: loadGatewaySessionRowMock, loadSessionEntry: () => ({ entry: undefined, storePath: "" }), loadSessionEntryReadOnly: loadGatewaySessionEntryReadOnlyMock, })); @@ -102,6 +103,7 @@ describe("createTranscriptUpdateBroadcastHandler", () => { isEmbeddedAgentRunInProgressMock.mockReturnValue(false); loadAccessorSessionEntryReadOnlyMock.mockReturnValue(undefined); loadGatewaySessionEntryReadOnlyMock.mockReturnValue({ entry: undefined, storePath: "" }); + loadGatewaySessionRowMock.mockReturnValue(sessionRow); readSessionMessageCountAsyncMock.mockResolvedValue(undefined); sessionRow.thinkingLevel = "ultra"; }); @@ -550,9 +552,95 @@ describe("createTranscriptUpdateBroadcastHandler", () => { }); expect(broadcastToConnIds.mock.calls[0]?.[1]).toMatchObject({ messageSeq: 7 }); }); + + it("does not stall one session's broadcasts behind another session's pending seq read", async () => { + let releaseSlowCount: (value: number | undefined) => void = () => undefined; + readSessionMessageCountAsyncMock.mockImplementation((params: { sessionKey?: string }) => + params.sessionKey === "agent:main:slow" + ? new Promise((resolve) => { + releaseSlowCount = resolve; + }) + : Promise.resolve(3), + ); + loadAccessorSessionEntryReadOnlyMock.mockReturnValue({ sessionId: "sess-main" }); + const { broadcastToConnIds, handler } = createHandler(false); + + // No messageSeq: the slow lane blocks on its async transcript count. + const slowTask = handler({ + message: { role: "assistant", content: [{ type: "text", text: "slow" }] }, + messageId: "slow-1", + target: { + agentId: "main", + sessionId: "sess-slow", + sessionKey: "agent:main:slow", + storePath: "/tmp/slow-sessions.json", + }, + }); + + await handler({ + sessionFile: "/tmp/sess-main.jsonl", + sessionKey: "agent:main:main", + message: { role: "assistant", content: [{ type: "text", text: "fast" }] }, + messageId: "fast-1", + messageSeq: 1, + }); + + // The independent lane broadcast completed while the slow lane is parked. + expect(broadcastToConnIds).toHaveBeenCalledTimes(1); + expect(broadcastToConnIds.mock.calls[0]?.[1]).toMatchObject({ messageId: "fast-1" }); + + releaseSlowCount(5); + await slowTask; + expect(broadcastToConnIds).toHaveBeenCalledTimes(2); + expect(broadcastToConnIds.mock.calls[1]?.[1]).toMatchObject({ messageId: "slow-1" }); + }); + + it("preserves message order within one session lane", async () => { + let releaseFirstCount: (value: number | undefined) => void = () => undefined; + readSessionMessageCountAsyncMock.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstCount = resolve; + }), + ); + loadAccessorSessionEntryReadOnlyMock.mockReturnValue({ sessionId: "sess-main" }); + const { broadcastToConnIds, handler } = createHandler(false); + + const firstTask = handler({ + message: { role: "assistant", content: [{ type: "text", text: "first" }] }, + messageId: "ordered-1", + target: { + agentId: "main", + sessionId: "sess-main", + sessionKey: "agent:main:main", + storePath: "/tmp/explicit-sessions.json", + }, + }); + const secondTask = handler({ + sessionFile: "/tmp/sess-main.jsonl", + sessionKey: "agent:main:main", + message: { role: "assistant", content: [{ type: "text", text: "second" }] }, + messageId: "ordered-2", + messageSeq: 2, + }); + + await Promise.resolve(); + expect(broadcastToConnIds).not.toHaveBeenCalled(); + + releaseFirstCount(1); + await Promise.all([firstTask, secondTask]); + expect(broadcastToConnIds.mock.calls.map((call) => call[1]?.messageId)).toEqual([ + "ordered-1", + "ordered-2", + ]); + }); }); describe("createLifecycleEventBroadcastHandler", () => { + beforeEach(() => { + loadGatewaySessionRowMock.mockReturnValue(sessionRow); + }); + it("projects swarm phase and log payload fields", () => { const broadcastToConnIds = vi.fn(); const handler = createLifecycleEventBroadcastHandler({ diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index d7806b79a1b5..036c40543526 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -164,7 +164,10 @@ export function createTranscriptUpdateBroadcastHandler(params: { sessionMessageSubscribers: SessionMessageSubscribers; chatAbortControllers: Map; }) { - let broadcastQueue = Promise.resolve(); + // Ordering is a per-transcript contract: subscribers merge each session's + // updates independently, so lanes keyed by transcript identity keep message + // order without one session's async seq reads stalling every other session. + const broadcastQueues = new Map>(); return (update: InternalSessionTranscriptUpdate): Promise => { // Capture legacy ownership before the async queue can cross a same-id reset; // committed producer ownership always wins over a later session-store read. @@ -174,10 +177,26 @@ export function createTranscriptUpdateBroadcastHandler(params: { ? readTranscriptUpdateLifecycleOwner(update)?.lifecycleRevision : undefined); const queuedUpdate = lifecycleRevision ? { ...update, lifecycleRevision } : update; - // Preserve transcript update order even when counting messages requires an - // async read from the session file. - const task = broadcastQueue.then(() => handleTranscriptUpdateBroadcast(params, queuedUpdate)); - broadcastQueue = task.catch(() => undefined); + const laneKey = + normalizeOptionalString(update.target?.sessionKey) ?? + normalizeOptionalString(update.sessionKey) ?? + normalizeOptionalString(update.sessionFile) ?? + ""; + // Preserve transcript update order within the lane even when counting + // messages requires an async read from the session file. + const tail = broadcastQueues.get(laneKey) ?? Promise.resolve(); + const task = tail.then(() => handleTranscriptUpdateBroadcast(params, queuedUpdate)); + const settled = task.then( + () => undefined, + () => undefined, + ); + broadcastQueues.set(laneKey, settled); + void settled.then(() => { + // Drop drained lanes so idle sessions do not accumulate map entries. + if (broadcastQueues.get(laneKey) === settled) { + broadcastQueues.delete(laneKey); + } + }); return task; }; } @@ -320,6 +339,8 @@ async function handleTranscriptUpdateBroadcast( return; } } + // Message frames must keep transcript-derived live usage (dashboard API + // contract from #50101); the 64KB cap bounds the per-message tail read. const sessionRow = loadGatewaySessionRow(sessionKey, { agentId: routingAgentId, transcriptUsageMaxBytes: 64 * 1024, diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index 0e175832af1a..1d5a3b94758e 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -201,7 +201,7 @@ describe("LabsPage", () => { }, { label: "Cloud Worker Desktop", - index: 6, + index: 7, sourceConfig: { cloudWorkers: { desktop: false } }, expectedPatch: { cloudWorkers: { desktop: true } }, note: "labs: update workerDesktop",