From 4cd68fafbb2ab3a4b0134bbff7c03c9a162e9ef4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 20:30:48 +0100 Subject: [PATCH] fix(sessions): ignore future freshness timestamps --- CHANGELOG.md | 1 + src/config/sessions/reset-policy.ts | 16 +++++-- src/config/sessions/sessions.test.ts | 64 +++++++++++++++++++++++++++- src/config/sessions/types.ts | 14 +++++- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad839c869c..3253fa0d4d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon. - Gateway/startup: keep hot Gateway boot paths on leaf config imports and add max-RSS reporting to the gateway startup bench so low-memory startup regressions are visible before release. Thanks @vincentkoc. - WebChat/TTS: persist automatic final-mode TTS audio as a supplemental audio-only transcript update instead of adding a second assistant message with the same visible text. Fixes #72830. Thanks @lhtpluto. - Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby. diff --git a/src/config/sessions/reset-policy.ts b/src/config/sessions/reset-policy.ts index 0191b90851b..ff6aba21c41 100644 --- a/src/config/sessions/reset-policy.ts +++ b/src/config/sessions/reset-policy.ts @@ -76,8 +76,10 @@ export function evaluateSessionFreshness(params: { now: number; policy: SessionResetPolicy; }): SessionFreshness { - const sessionStartedAt = resolveTimestamp(params.sessionStartedAt) ?? params.updatedAt; - const lastInteractionAt = resolveTimestamp(params.lastInteractionAt) ?? sessionStartedAt; + const updatedAt = resolveTimestamp(params.updatedAt, params.now) ?? 0; + const sessionStartedAt = resolveTimestamp(params.sessionStartedAt, params.now) ?? updatedAt; + const lastInteractionAt = + resolveTimestamp(params.lastInteractionAt, params.now) ?? sessionStartedAt; const dailyResetAt = params.policy.mode === "daily" ? resolveDailyResetAtMs(params.now, params.policy.atHour) @@ -95,8 +97,14 @@ export function evaluateSessionFreshness(params: { }; } -function resolveTimestamp(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +function resolveTimestamp(value: number | undefined, now?: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + if (typeof now === "number" && Number.isFinite(now) && value > now) { + return undefined; + } + return value; } function normalizeResetAtHour(value: number | undefined): number { diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index e33ecddcdb6..53f9cd6b983 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -18,7 +18,7 @@ import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js" import { resolveAndPersistSessionFile } from "./session-file.js"; import { clearSessionStoreCacheForTest, loadSessionStore, updateSessionStore } from "./store.js"; import { useTempSessionsFixture } from "./test-helpers.js"; -import { mergeSessionEntry, type SessionEntry } from "./types.js"; +import { mergeSessionEntry, mergeSessionEntryWithPolicy, type SessionEntry } from "./types.js"; describe("session path safety", () => { it("rejects unsafe session IDs", () => { @@ -202,6 +202,38 @@ describe("resolveSessionResetPolicy", () => { idleExpiresAt: 5 * 60_000, }); }); + + it("does not let future legacy updatedAt values keep daily sessions fresh", () => { + const now = new Date(2026, 3, 25, 12, 0, 0, 0).getTime(); + const freshness = evaluateSessionFreshness({ + updatedAt: now + 30 * 24 * 60 * 60_000, + now, + policy: { + mode: "daily", + atHour: 4, + }, + }); + + expect(freshness.fresh).toBe(false); + }); + + it("does not let future legacy updatedAt values keep idle sessions fresh", () => { + const now = 60 * 60_000; + const freshness = evaluateSessionFreshness({ + updatedAt: now + 30 * 24 * 60 * 60_000, + now, + policy: { + mode: "idle", + atHour: 4, + idleMinutes: 5, + }, + }); + + expect(freshness).toMatchObject({ + fresh: false, + idleExpiresAt: 5 * 60_000, + }); + }); }); describe("session lifecycle timestamps", () => { @@ -349,6 +381,36 @@ describe("session store lock (Promise chain mutex)", () => { expect(merged.modelProvider).toBeUndefined(); }); + it("caps future updatedAt values at the session merge boundary", () => { + const now = 1_000; + const merged = mergeSessionEntryWithPolicy( + { + sessionId: "sess-future", + updatedAt: now + 10_000, + }, + { + updatedAt: now + 20_000, + }, + { now }, + ); + + expect(merged.updatedAt).toBe(now); + }); + + it("caps future updatedAt values while preserving activity", () => { + const now = 1_000; + const merged = mergeSessionEntryWithPolicy( + { + sessionId: "sess-preserve-future", + updatedAt: now + 10_000, + }, + {}, + { now, policy: "preserve-activity" }, + ); + + expect(merged.updatedAt).toBe(now); + }); + it("normalizes orphan modelProvider fields at store write boundary", async () => { const key = "agent:main:orphan-provider"; const { storePath } = await makeTmpStore({ diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index e4e2e2e38de..45a8cde9677 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -379,10 +379,20 @@ function resolveMergedUpdatedAt( patch: Partial, options?: MergeSessionEntryOptions, ): number { + const now = options?.now ?? Date.now(); + const existingUpdatedAt = normalizeMergedUpdatedAt(existing?.updatedAt, now); + const patchUpdatedAt = normalizeMergedUpdatedAt(patch.updatedAt, now); if (options?.policy === "preserve-activity" && existing) { - return existing.updatedAt ?? patch.updatedAt ?? options.now ?? Date.now(); + return existingUpdatedAt ?? patchUpdatedAt ?? now; } - return Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, options?.now ?? Date.now()); + return Math.max(existingUpdatedAt ?? 0, patchUpdatedAt ?? 0, now); +} + +function normalizeMergedUpdatedAt(value: number | undefined, now: number): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.min(value, now); } export function mergeSessionEntryWithPolicy(