diff --git a/src/auto-reply/reply/agent-runner-helpers.test.ts b/src/auto-reply/reply/agent-runner-helpers.test.ts index d478af66985..5aaab51fec3 100644 --- a/src/auto-reply/reply/agent-runner-helpers.test.ts +++ b/src/auto-reply/reply/agent-runner-helpers.test.ts @@ -59,6 +59,9 @@ describe("agent runner helpers", () => { }); expect(shouldEmitResult()).toBe(true); expect(shouldEmitOutput()).toBe(true); + expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/tmp/store.json", { + clone: false, + }); }); it("caches session verbose reads briefly while still refreshing live changes", () => { diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index 12a35381278..2eac9a92409 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -27,7 +27,7 @@ function readCurrentVerboseLevel(params: VerboseGateParams): VerboseLevel | unde return undefined; } try { - const store = loadSessionStore(params.storePath); + const store = loadSessionStore(params.storePath, { clone: false }); const entry = store[params.sessionKey]; return typeof entry?.verboseLevel === "string" ? normalizeVerboseLevel(entry.verboseLevel) diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index 0f8cdabcffc..e7cd13b875a 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -444,6 +444,7 @@ describe("Session Store Cache", () => { expect(readSessionUpdatedAt({ storePath, sessionKey: "agent:main:main" })).toBe(updatedAt); expect(parseSpy).not.toHaveBeenCalled(); + expect(getSessionStoreSnapshotCacheStatsForTest().entries).toBe(1); parseSpy.mockRestore(); }); @@ -507,6 +508,33 @@ describe("Session Store Cache", () => { expect(readSessionStoreSnapshot(storePath)["session:1"].origin?.provider).toBe("openai"); }); + it("reads immutable single entries without populating whole-store snapshots", async () => { + await saveSessionStore(storePath, { + "session:1": createSessionEntry({ + sessionId: "id-1", + skillsSnapshot: { + prompt: "single entry prompt ".repeat(200), + skills: [{ name: "alpha" }], + }, + }), + "session:2": createSessionEntry({ sessionId: "id-2" }), + }); + clearSessionStoreCacheForTest(); + + const entry = readSessionEntry(storePath, "session:1"); + + expect(entry?.sessionId).toBe("id-1"); + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry?.skillsSnapshot?.skills)).toBe(true); + expect(getSessionStoreSnapshotCacheStatsForTest().entries).toBe(0); + + const cached = loadSessionStore(storePath, { clone: false }); + expect(() => { + (entry as SessionEntry).displayName = "mutated returned entry"; + }).toThrow(TypeError); + expect(cached["session:1"].displayName).toBe("Test Session 1"); + }); + it("does not tag snapshots with stats from writes racing after a disk read", async () => { await saveSessionStore( storePath, diff --git a/src/config/sessions/store-cache.ts b/src/config/sessions/store-cache.ts index 3441705ca5d..71c3cbfb335 100644 --- a/src/config/sessions/store-cache.ts +++ b/src/config/sessions/store-cache.ts @@ -243,6 +243,10 @@ export function cloneSessionStoreSnapshot( return deepFreeze(cloned); } +export function cloneSessionStoreSnapshotEntry(entry: SessionEntry): SessionStoreSnapshotEntry { + return deepFreeze(cloneSessionStoreRecord({ entry }).entry); +} + export function getSessionStoreTtl(): number { return resolveCacheTtlMs({ envValue: process.env.OPENCLAW_SESSION_CACHE_TTL_MS, diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index b83ae2ff92d..58d418425b3 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -13,6 +13,7 @@ import { getFileStatSnapshot } from "../cache-utils.js"; import { hydrateSessionStoreSkillPromptRefs } from "./skill-prompt-blobs.js"; import { cloneSessionStoreRecord, + cloneSessionStoreSnapshotEntry, cloneSessionStoreSnapshot, internSessionEntryLargeStrings, isSessionStoreCacheEnabled, @@ -488,12 +489,12 @@ export function readSessionEntry( storePath: string, sessionKey: string, ): SessionStoreSnapshotEntry | undefined { - const snapshot = readSessionStoreSnapshot(storePath); + const store = loadSessionStore(storePath, { clone: false }); const resolved = resolveSessionStoreEntry({ - store: snapshot as Record, + store, sessionKey, }); - return resolved.existing as SessionStoreSnapshotEntry | undefined; + return resolved.existing ? cloneSessionStoreSnapshotEntry(resolved.existing) : undefined; } export function readSessionEntries(storePath: string): SessionStoreSnapshotEntries { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 123e90e1344..4574aaa4c2c 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -113,7 +113,8 @@ export function readSessionUpdatedAt(params: { sessionKey: string; }): number | undefined { try { - return readSessionEntry(params.storePath, params.sessionKey)?.updatedAt; + const store = loadSessionStore(params.storePath, { clone: false }); + return resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing?.updatedAt; } catch { return undefined; }