From 5477cc757f86f49a82670c1adf8545a5881fba7c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 12 Aug 2026 19:40:31 +0200 Subject: [PATCH] fix(sessions): route snapshot transcript writes by owner --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../session-accessor.transcript-turn.ts | 46 +++++++---- .../chat.directive-tags.test.ts | 78 +++++++++++++++++-- 18 files changed, 117 insertions(+), 39 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index dacf37893f6f..c68053af4d6d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"48e9f1762613441ec71879de8fcfffcf0d382867b55436716ee00459d7b4f49b","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"8f18517eea8e82b6fc749ae5cb42d21b4f1406b51e16c70203205315983e216f","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index bf3b043d67da..c8c48f15a53d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"fbc1f78eb076fb8e8e966f7cbf377303ec162cc3f2c5fa50e146d2e73618560f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"13aa9f6d8fee157fdf4d98d524da8e21423eaad57ec3612ee98df483ad8bd507","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index ab4bd4186547..cd696ab194e6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"02987e8974ddb4d4ac590a9d9fedb620689a7ef198b78c4cb87120dbb546fbd2","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"6d225dda2653c8558cc42665fb3ba787fd4dd02f970b76e8eb2386b053dab6cf","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 1a1f41f27f9b..ec052fad33bf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"a531034eea4aa11ef247468055c90ac2be9b90eed15d20bcd621ba89647e61b3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"2a322de029d348559b6e25414b9ca484fb440ab6a832440bb7b9744eefa25a03","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index fea656d693c1..0cef33641f53 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"db916c141015c7ec52d0f3efc7fcb855084e81d8502d610d63f3eb78f5b7a8e5","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"d4e97e41bf610c01a2bd48308a2a60f8951cd9c5d204b8575f43515cf6e897bd","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 30fc62b0d6da..0eb458d6e2ef 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"7ac53597410b8bd7a525462e3e1598408010711d9cb2b77d68ad69332059ba7a","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"ec6e168cb49b72994adc5ee6e5eaae7c2181cf51f2bb61ce74d6f8d186d0148d","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 7744eafba266..2c0341324398 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"7cf6e6ddd0bbb3c40fb012760ebcdce17834d9347bef430d4e7f2efd93db1f1e","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"60290b8598657d224562ca9eb78377193da6e91da05b0373240af43e05e1b241","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 05c8c5f5fcb1..b49cd7ff3b25 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"0c76b2fd651acded8caf3f8c76f7ee292b530b6e0a052cb4f0688fc0bc00a1c0","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"ed28932f6d3093ad7399064865a14afd44e7a6272d957ec0f919786f0dc12f62","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 33e3e16a9544..2c6571c3f9ba 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"19c444b6b2eb6062adabf88dc92060553042071de32d1113e2dae4a528d24fa6","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"9bca101dfadd456fa88d33bb610d2f8290b840110bddfbeac3bbe32eb8677edd","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 478d8a377b96..9462e98b82df 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"ae337f8842b30f3d63ae5dd90963e7b73894cff02a3aea4b6f218ed0715fdd74","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"0c1ea4c591fa0c0281bbe7bf261f9f4e39977b52133763c95a5cffa91c47b89d","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index b90335be2863..991fc35ed7ae 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"8c90eea3929b848916e5d4aaa67e8dac636e47e8ab766a5260b888d3f2dd6cfb","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"91b285baa5bfa0a040cb54e63971e4bcebcb14b11ca206ec7d59cf20cd7f04be","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index a000e082935a..17bd22136ae5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"ec039f279134221e3937ed3388e0cc0f065e2ab0a62a80b42384d7a10cbd98c3","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"ffc87bb6abfe73b76cb379a9d6da485cce98500053f4ca2b3abf338ef8178e95","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 33b125630318..73aa92ba37e7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"c86462cf73045c6a2eb37a77be545b3148b58890dd0b112750d24b0829ad6166","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"470acf51a08826d82d08316d0f9607e97d954ffd840aafa9bb6bd9aef0106ba7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index f6ec3b491fa3..8c1022a8526d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"9cd0143763f80869c7182fcab934d493a0106d0b358d989fd64270fae540c73c","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"5003150fed73cdbb9a19e4309a070fa657c7625e1b44b36d44f987131379afde","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 3c0b2305ef7f..e17256ee9301 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"90cb8357bc1472f7b5df268aa40850fbc8d33e401e3f9d5b7ea73f7b0842b0ea","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"026a053659a9f728a9452da4d69f4581213074eeea930f07000377cbac48a9d7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 39eddd7fe39e..fe15839e3aff 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"81022c7a14e82a7d0b50aaec10fa9d6c5494e2cd8398aa1bc4fe4556f5fbfdcf","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"db7793d03f781b3044dce9497370f591b44fdcec2afc36d02e173e712c6e66fd","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index a63dc6b942ec..b04a6f8a1ae2 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -281,8 +281,8 @@ async function persistExpectedSessionTranscriptTurn( expectedSessionId: string; }, ): Promise { - const sessionKey = scope.sessionKey?.trim(); - if (!scope.storePath || !sessionKey) { + const requestedSessionKey = scope.sessionKey?.trim(); + if (!scope.storePath || !requestedSessionKey) { throw new Error("Cannot guard a transcript turn without a session store and key"); } const storePath = scope.storePath; @@ -290,11 +290,19 @@ async function persistExpectedSessionTranscriptTurn( const agentId = resolveTranscriptTurnAgentId({ config: options.config ?? getRuntimeConfig(), scopeAgentId: scope.agentId, - sessionKey, + sessionKey: requestedSessionKey, storePath, sessionStore: scope.sessionStore, env: scope.env, }); + const runtimeTarget = await resolveSessionTranscriptRuntimeTarget({ + agentId, + ...(scope.env ? { env: scope.env } : {}), + sessionId: expectedSessionId, + sessionKey: requestedSessionKey, + storePath, + }); + const sessionKey = runtimeTarget.sessionKey; const resolved = scope.sessionStore ? resolveSessionEntryFromStore({ store: scope.sessionStore, sessionKey }) : resolveSessionEntrySelection({ @@ -355,8 +363,13 @@ async function persistExpectedSessionTranscriptTurn( }; } + // The requested key remains the caller's live update route; the resolved + // target above is the distinct physical SQLite owner. await publishTranscriptTurnUpdate({ - target, + target: + requestedSessionKey === target.sessionKey + ? target + : { ...target, sessionKey: requestedSessionKey }, sessionEntry: turn.sessionEntry, updateMode: options.updateMode ?? "inline", publishWhen: options.publishWhen ?? "when-appended", @@ -404,16 +417,17 @@ async function resolveTranscriptTurnTarget( agentId, env: scope.env, }); - const runtimeTarget = scope.sessionStore - ? undefined - : await resolveSessionTranscriptRuntimeTarget({ - agentId, - ...(scope.env ? { env: scope.env } : {}), - sessionId: scope.sessionId, - sessionKey, - storePath, - }); - const resolvedSessionKey = runtimeTarget?.sessionKey ?? sessionKey; + // A caller snapshot may retain the routing key that admitted the turn. The + // persisted window owns durable writes; resolving it is read-only, so a + // memory-only mirror still avoids materializing SQLite state. + const runtimeTarget = await resolveSessionTranscriptRuntimeTarget({ + agentId, + ...(scope.env ? { env: scope.env } : {}), + sessionId: scope.sessionId, + sessionKey, + storePath, + }); + const resolvedSessionKey = runtimeTarget.sessionKey; const resolved = scope.sessionStore ? resolveSessionEntryFromStore({ store: scope.sessionStore, sessionKey: resolvedSessionKey }) : undefined; @@ -425,11 +439,11 @@ async function resolveTranscriptTurnTarget( sessionKey: resolvedSessionKey, storePath, }); - const sessionEntry = resolved?.existing ?? scope.sessionEntry ?? persistedEntry; + const sessionEntry = persistedEntry ?? resolved?.existing ?? scope.sessionEntry; return { agentId, sessionId: scope.sessionId, - sessionKey: runtimeTarget?.sessionKey ?? resolved?.normalizedKey ?? resolvedSessionKey, + sessionKey: runtimeTarget.sessionKey, storePath, sessionEntry, entryFromPersistedStore: persistedEntry != null, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 7ca7971e72c8..472a7ad34f1c 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -673,7 +673,13 @@ function createFixturePaths(prefix: string): { dir: string; transcriptPath: stri return { dir, transcriptPath }; } -async function createTranscriptFixture(prefix: string) { +async function createTranscriptFixture( + prefix: string, + owner: Pick = { + agentId: "main", + sessionKey: "main", + }, +) { const { dir, transcriptPath } = createFixturePaths(prefix); fs.writeFileSync( transcriptPath, @@ -688,11 +694,14 @@ async function createTranscriptFixture(prefix: string) { ); // The accessor resolves transcript targets from the persisted store, so the // fixture seeds a real entry instead of relying on the mocked gateway wrapper. - await replaceSessionEntry(sessionEntryScope(), { - sessionId: mockState.sessionId, - sessionFile: transcriptPath, - updatedAt: Date.now(), - }); + await replaceSessionEntry( + { ...owner, storePath: mockState.storePath }, + { + sessionId: mockState.sessionId, + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + ); return dir; } @@ -5040,7 +5049,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("chat.inject scopes selected-agent global sessions before appending", async () => { - await createTranscriptFixture("openclaw-chat-inject-selected-global-"); + await createTranscriptFixture("openclaw-chat-inject-selected-global-", { + agentId: "work", + sessionKey: "agent:work:global", + }); mockState.config = { agents: { list: [{ id: "main", default: true }, { id: "work" }] }, session: { scope: "global" }, @@ -7079,6 +7091,58 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(getTotalPendingReplies()).toBe(0); }); + it("persists a Gateway user turn under the durable owner when its loaded key is stale", async () => { + createFixturePaths("openclaw-chat-send-stale-transcript-owner-"); + const canonicalSessionKey = "agent:main:canonical-transcript-owner"; + const staleSessionKey = "agent:main:stale-transcript-owner"; + await replaceSessionEntry( + { + agentId: "main", + sessionKey: canonicalSessionKey, + storePath: mockState.storePath, + }, + { sessionId: mockState.sessionId, updatedAt: 1 }, + ); + mockState.finalText = "ok"; + const { send } = createChatRequestFixture(); + + await send({ + idempotencyKey: "idem-stale-transcript-owner", + message: "keep this Gateway turn", + sessionKey: staleSessionKey, + expectBroadcast: false, + }); + + const persistedEvents = loadTranscriptEventsSync({ + agentId: "main", + sessionId: mockState.sessionId, + sessionKey: canonicalSessionKey, + storePath: mockState.storePath, + }); + expect(persistedEvents).toContainEqual( + expect.objectContaining({ + type: "message", + message: expect.objectContaining({ + role: "user", + content: "keep this Gateway turn", + }), + }), + ); + expect( + loadSqliteSessionEntry({ + agentId: "main", + sessionKey: staleSessionKey, + storePath: mockState.storePath, + }), + ).toBeUndefined(); + expect(findUserUpdate()?.target).toEqual({ + agentId: "main", + sessionId: mockState.sessionId, + sessionKey: staleSessionKey, + storePath: mockState.storePath, + }); + }); + it("emits a user transcript update when chat.send fails before an agent run starts", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-user-transcript-error-no-run-"); mockState.dispatchError = new Error("upstream unavailable");