Skip to content

Commit 4cd68fa

Browse files
committed
fix(sessions): ignore future freshness timestamps
1 parent 54e13d4 commit 4cd68fa

4 files changed

Lines changed: 88 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515

1616
### Fixes
1717

18+
- 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.
1819
- 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.
1920
- 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.
2021
- 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.

src/config/sessions/reset-policy.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@ export function evaluateSessionFreshness(params: {
7676
now: number;
7777
policy: SessionResetPolicy;
7878
}): SessionFreshness {
79-
const sessionStartedAt = resolveTimestamp(params.sessionStartedAt) ?? params.updatedAt;
80-
const lastInteractionAt = resolveTimestamp(params.lastInteractionAt) ?? sessionStartedAt;
79+
const updatedAt = resolveTimestamp(params.updatedAt, params.now) ?? 0;
80+
const sessionStartedAt = resolveTimestamp(params.sessionStartedAt, params.now) ?? updatedAt;
81+
const lastInteractionAt =
82+
resolveTimestamp(params.lastInteractionAt, params.now) ?? sessionStartedAt;
8183
const dailyResetAt =
8284
params.policy.mode === "daily"
8385
? resolveDailyResetAtMs(params.now, params.policy.atHour)
@@ -95,8 +97,14 @@ export function evaluateSessionFreshness(params: {
9597
};
9698
}
9799

98-
function resolveTimestamp(value: number | undefined): number | undefined {
99-
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
100+
function resolveTimestamp(value: number | undefined, now?: number): number | undefined {
101+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
102+
return undefined;
103+
}
104+
if (typeof now === "number" && Number.isFinite(now) && value > now) {
105+
return undefined;
106+
}
107+
return value;
100108
}
101109

102110
function normalizeResetAtHour(value: number | undefined): number {

src/config/sessions/sessions.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js"
1818
import { resolveAndPersistSessionFile } from "./session-file.js";
1919
import { clearSessionStoreCacheForTest, loadSessionStore, updateSessionStore } from "./store.js";
2020
import { useTempSessionsFixture } from "./test-helpers.js";
21-
import { mergeSessionEntry, type SessionEntry } from "./types.js";
21+
import { mergeSessionEntry, mergeSessionEntryWithPolicy, type SessionEntry } from "./types.js";
2222

2323
describe("session path safety", () => {
2424
it("rejects unsafe session IDs", () => {
@@ -202,6 +202,38 @@ describe("resolveSessionResetPolicy", () => {
202202
idleExpiresAt: 5 * 60_000,
203203
});
204204
});
205+
206+
it("does not let future legacy updatedAt values keep daily sessions fresh", () => {
207+
const now = new Date(2026, 3, 25, 12, 0, 0, 0).getTime();
208+
const freshness = evaluateSessionFreshness({
209+
updatedAt: now + 30 * 24 * 60 * 60_000,
210+
now,
211+
policy: {
212+
mode: "daily",
213+
atHour: 4,
214+
},
215+
});
216+
217+
expect(freshness.fresh).toBe(false);
218+
});
219+
220+
it("does not let future legacy updatedAt values keep idle sessions fresh", () => {
221+
const now = 60 * 60_000;
222+
const freshness = evaluateSessionFreshness({
223+
updatedAt: now + 30 * 24 * 60 * 60_000,
224+
now,
225+
policy: {
226+
mode: "idle",
227+
atHour: 4,
228+
idleMinutes: 5,
229+
},
230+
});
231+
232+
expect(freshness).toMatchObject({
233+
fresh: false,
234+
idleExpiresAt: 5 * 60_000,
235+
});
236+
});
205237
});
206238

207239
describe("session lifecycle timestamps", () => {
@@ -349,6 +381,36 @@ describe("session store lock (Promise chain mutex)", () => {
349381
expect(merged.modelProvider).toBeUndefined();
350382
});
351383

384+
it("caps future updatedAt values at the session merge boundary", () => {
385+
const now = 1_000;
386+
const merged = mergeSessionEntryWithPolicy(
387+
{
388+
sessionId: "sess-future",
389+
updatedAt: now + 10_000,
390+
},
391+
{
392+
updatedAt: now + 20_000,
393+
},
394+
{ now },
395+
);
396+
397+
expect(merged.updatedAt).toBe(now);
398+
});
399+
400+
it("caps future updatedAt values while preserving activity", () => {
401+
const now = 1_000;
402+
const merged = mergeSessionEntryWithPolicy(
403+
{
404+
sessionId: "sess-preserve-future",
405+
updatedAt: now + 10_000,
406+
},
407+
{},
408+
{ now, policy: "preserve-activity" },
409+
);
410+
411+
expect(merged.updatedAt).toBe(now);
412+
});
413+
352414
it("normalizes orphan modelProvider fields at store write boundary", async () => {
353415
const key = "agent:main:orphan-provider";
354416
const { storePath } = await makeTmpStore({

src/config/sessions/types.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,10 +379,20 @@ function resolveMergedUpdatedAt(
379379
patch: Partial<SessionEntry>,
380380
options?: MergeSessionEntryOptions,
381381
): number {
382+
const now = options?.now ?? Date.now();
383+
const existingUpdatedAt = normalizeMergedUpdatedAt(existing?.updatedAt, now);
384+
const patchUpdatedAt = normalizeMergedUpdatedAt(patch.updatedAt, now);
382385
if (options?.policy === "preserve-activity" && existing) {
383-
return existing.updatedAt ?? patch.updatedAt ?? options.now ?? Date.now();
386+
return existingUpdatedAt ?? patchUpdatedAt ?? now;
384387
}
385-
return Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, options?.now ?? Date.now());
388+
return Math.max(existingUpdatedAt ?? 0, patchUpdatedAt ?? 0, now);
389+
}
390+
391+
function normalizeMergedUpdatedAt(value: number | undefined, now: number): number | undefined {
392+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
393+
return undefined;
394+
}
395+
return Math.min(value, now);
386396
}
387397

388398
export function mergeSessionEntryWithPolicy(

0 commit comments

Comments
 (0)