mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
committed by
GitHub
parent
e04dfd26e2
commit
177a16cdca
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user