fix(sessions): ignore future freshness timestamps

This commit is contained in:
Peter Steinberger
2026-04-27 20:30:48 +01:00
parent 54e13d4910
commit 4cd68fafbb
4 changed files with 88 additions and 7 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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({

View File

@@ -379,10 +379,20 @@ function resolveMergedUpdatedAt(
patch: Partial<SessionEntry>,
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(