mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:40:49 +00:00
fix(sessions): ignore future freshness timestamps
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ export function evaluateSessionFreshness(params: {
|
|||||||
now: number;
|
now: number;
|
||||||
policy: SessionResetPolicy;
|
policy: SessionResetPolicy;
|
||||||
}): SessionFreshness {
|
}): SessionFreshness {
|
||||||
const sessionStartedAt = resolveTimestamp(params.sessionStartedAt) ?? params.updatedAt;
|
const updatedAt = resolveTimestamp(params.updatedAt, params.now) ?? 0;
|
||||||
const lastInteractionAt = resolveTimestamp(params.lastInteractionAt) ?? sessionStartedAt;
|
const sessionStartedAt = resolveTimestamp(params.sessionStartedAt, params.now) ?? updatedAt;
|
||||||
|
const lastInteractionAt =
|
||||||
|
resolveTimestamp(params.lastInteractionAt, params.now) ?? sessionStartedAt;
|
||||||
const dailyResetAt =
|
const dailyResetAt =
|
||||||
params.policy.mode === "daily"
|
params.policy.mode === "daily"
|
||||||
? resolveDailyResetAtMs(params.now, params.policy.atHour)
|
? resolveDailyResetAtMs(params.now, params.policy.atHour)
|
||||||
@@ -95,8 +97,14 @@ export function evaluateSessionFreshness(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTimestamp(value: number | undefined): number | undefined {
|
function resolveTimestamp(value: number | undefined, now?: number): number | undefined {
|
||||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 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 {
|
function normalizeResetAtHour(value: number | undefined): number {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js"
|
|||||||
import { resolveAndPersistSessionFile } from "./session-file.js";
|
import { resolveAndPersistSessionFile } from "./session-file.js";
|
||||||
import { clearSessionStoreCacheForTest, loadSessionStore, updateSessionStore } from "./store.js";
|
import { clearSessionStoreCacheForTest, loadSessionStore, updateSessionStore } from "./store.js";
|
||||||
import { useTempSessionsFixture } from "./test-helpers.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", () => {
|
describe("session path safety", () => {
|
||||||
it("rejects unsafe session IDs", () => {
|
it("rejects unsafe session IDs", () => {
|
||||||
@@ -202,6 +202,38 @@ describe("resolveSessionResetPolicy", () => {
|
|||||||
idleExpiresAt: 5 * 60_000,
|
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", () => {
|
describe("session lifecycle timestamps", () => {
|
||||||
@@ -349,6 +381,36 @@ describe("session store lock (Promise chain mutex)", () => {
|
|||||||
expect(merged.modelProvider).toBeUndefined();
|
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 () => {
|
it("normalizes orphan modelProvider fields at store write boundary", async () => {
|
||||||
const key = "agent:main:orphan-provider";
|
const key = "agent:main:orphan-provider";
|
||||||
const { storePath } = await makeTmpStore({
|
const { storePath } = await makeTmpStore({
|
||||||
|
|||||||
@@ -379,10 +379,20 @@ function resolveMergedUpdatedAt(
|
|||||||
patch: Partial<SessionEntry>,
|
patch: Partial<SessionEntry>,
|
||||||
options?: MergeSessionEntryOptions,
|
options?: MergeSessionEntryOptions,
|
||||||
): number {
|
): 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) {
|
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(
|
export function mergeSessionEntryWithPolicy(
|
||||||
|
|||||||
Reference in New Issue
Block a user