fix(gateway): terminal run persistence race caches pre-terminal session rows forever (#123259)

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.
This commit is contained in:
Peter Steinberger
2026-08-13 15:37:45 -07:00
committed by GitHub
parent e04dfd26e2
commit 177a16cdca
3 changed files with 46 additions and 0 deletions
@@ -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<GatewayRequestContext, SessionListStat
function readSessionListFence(context: GatewayRequestContext): SessionListFence {
return {
agentRunIndexVersion: readAgentRunIndexVersion(),
lifecyclePersistenceVersion: readSessionLifecyclePersistenceVersion(),
sessionIdentityMutationVersion: readSessionIdentityMutationVersion(),
sessionsMutationVersion: readSessionsMutationVersion(context),
titleProjectionUnavailableVersion: readSessionTitleProjectionUnavailableVersion(),
@@ -40,6 +43,7 @@ function readSessionListFence(context: GatewayRequestContext): SessionListFence
function matchesSessionListFence(value: SessionListFence, fence: SessionListFence): boolean {
return (
value.agentRunIndexVersion === fence.agentRunIndexVersion &&
value.lifecyclePersistenceVersion === fence.lifecyclePersistenceVersion &&
value.sessionIdentityMutationVersion === fence.sessionIdentityMutationVersion &&
value.sessionsMutationVersion === fence.sessionsMutationVersion &&
value.titleProjectionUnavailableVersion === fence.titleProjectionUnavailableVersion &&
@@ -17,6 +17,7 @@ import { resetAgentEventsForTest } from "../../infra/agent-events.js";
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { persistGatewaySessionLifecycleEvent } from "../session-lifecycle-state.js";
import type { GatewaySessionRow } from "../session-utils.types.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
@@ -255,6 +256,36 @@ describe("sessions.list single-flight", () => {
});
});
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();
+11
View File
@@ -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;
}