From 177a16cdca33930ca107b4814e7e689856bd9cb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 15:37:45 -0700 Subject: [PATCH] fix(gateway): terminal run persistence race caches pre-terminal session rows forever (#123259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On lifecycle end the gateway clears run projections synchronously (bumping the run-index fence) and then commits the terminal entry write (status/endedAt/runtimeMs) asynchronously. A sessions.list computed in that window sees hasActiveRun=false with the pre-terminal entry — a cacheable wrong row — and the completed-result cache serves it indefinitely because the async commit bumps no fence; the post-persist notification is a raw broadcast that clients answer from the same stale cache. Give the lifecycle writer its own fence version, bumped when the commit lands, and add it to the list fence. --- .../server-methods/sessions-list-cache.ts | 4 +++ .../sessions-read-cache.test.ts | 31 +++++++++++++++++++ src/gateway/session-lifecycle-state.ts | 11 +++++++ 3 files changed, 46 insertions(+) diff --git a/src/gateway/server-methods/sessions-list-cache.ts b/src/gateway/server-methods/sessions-list-cache.ts index dab4aa306f38..115bece36a07 100644 --- a/src/gateway/server-methods/sessions-list-cache.ts +++ b/src/gateway/server-methods/sessions-list-cache.ts @@ -2,6 +2,7 @@ import type { SessionsListParams } from "../../../packages/gateway-protocol/src/ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { readAgentRunIndexVersion } from "../../infra/agent-run-registry.js"; import { readSessionIdentityMutationVersion } from "../../sessions/session-lifecycle-events.js"; +import { readSessionLifecyclePersistenceVersion } from "../session-lifecycle-state.js"; import { isGatewayAdmin } from "../session-sharing.js"; import { readSessionTitleProjectionUnavailableVersion } from "../session-transcript-title-reader.js"; import type { SessionsListResult } from "../session-utils.types.js"; @@ -11,6 +12,7 @@ import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js type SessionListFence = { agentRunIndexVersion: number; + lifecyclePersistenceVersion: number; sessionIdentityMutationVersion: number; sessionsMutationVersion: number; titleProjectionUnavailableVersion: number; @@ -30,6 +32,7 @@ const sessionListsByContext = new WeakMap { }); }); + it("invalidates a completed result after terminal lifecycle persistence lands", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const config = await seedSessions(); + const context = requestContext(config); + const client = identifiedClient("owner@example.com"); + const request = { archived: "all" as const, limit: 100 }; + const clock = vi.spyOn(Date, "now").mockReturnValue(60_400); + + const first = await listSessions({ client, context, request }); + clock.mockReturnValue(60_401); + expect(await listSessions({ client, context, request })).toBe(first); + expect(loader.calls).toHaveBeenCalledTimes(1); + + // The terminal entry write (status/endedAt/runtimeMs) commits after the + // run-index fence bumped at lifecycle end. A list computed in that + // window cached the pre-terminal row; the persistence fence evicts it. + await persistGatewaySessionLifecycleEvent({ + sessionKey: "agent:main:active", + agentId: "main", + event: { + ts: 60_500, + runId: "run-terminal-fence", + data: { phase: "end", startedAt: 60_000, endedAt: 60_450 }, + }, + }); + await listSessions({ client, context, request }); + expect(loader.calls).toHaveBeenCalledTimes(2); + }); + }); + it("does not cache title rows degraded during projection rebuild", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const config = await seedSessions(); diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index 4a2367fe381c..2cf0e7d7149d 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -298,6 +298,16 @@ function acceptsCronRunContinuationLifecycleEvent(params: { return Boolean(marker?.phase === "continuing" && runId && marker.ownerRunId === runId); } +// sessions.list cache fence input. The terminal entry write (status/endedAt/ +// runtimeMs) commits asynchronously after the run-index fence already bumped +// at lifecycle end; without its own fence a list computed in that window +// caches the pre-terminal row indefinitely. +let lifecyclePersistenceVersion = 0; + +export function readSessionLifecyclePersistenceVersion(): number { + return lifecyclePersistenceVersion; +} + export async function persistGatewaySessionLifecycleEvent(params: { sessionKey: string; agentId?: string; @@ -360,4 +370,5 @@ export async function persistGatewaySessionLifecycleEvent(params: { requireWriteSuccess: true, }, ); + lifecyclePersistenceVersion += 1; }