diff --git a/CHANGELOG.md b/CHANGELOG.md index 085a899e2d1..ddbe31891c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Discord/voice: rerun configured voice auto-join after Discord gateway RESUMED events and ignore already-destroyed stale voice connections during reconnect cleanup, so health-monitor account restarts can rejoin configured channels. Fixes #40665. Thanks @liz709. - Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim. - Gateway/health: refresh cached health RPC snapshots when channel runtime state diverges, so Discord and other channel status reads no longer report stale running or connected values until the cache TTL expires. (#75423) Thanks @clawsweeper. +- Gateway/sessions: keep session-store reads from running stale prune and entry-count cap maintenance during startup, so oversized stores no longer block chat history readiness after updates while writes and `sessions cleanup --enforce` still preserve the cleanup safeguards. Fixes #70050. Thanks @tangda18. - Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep. - WhatsApp: stage `qrcode` through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001. - Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index bc1e7acd851..d3bd792692e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -125,7 +125,7 @@ to `"enforce"` for automatic cleanup: } ``` -For production-sized `maxEntries` limits, Gateway runtime writes use a small high-water buffer and clean back down to the configured cap in batches. This avoids running full store cleanup on every isolated cron session. `openclaw sessions cleanup --enforce` applies the cap immediately. +For production-sized `maxEntries` limits, Gateway runtime writes use a small high-water buffer and clean back down to the configured cap in batches. Session store reads do not prune or cap entries during Gateway startup. This avoids running full store cleanup on every startup or isolated cron session. `openclaw sessions cleanup --enforce` applies the cap immediately. Preview with `openclaw sessions cleanup --dry-run`. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 4e11fb71842..946eaa4de2d 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -79,7 +79,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f - `maxDiskBytes`: optional sessions-directory budget - `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) -Normal Gateway writes batch `maxEntries` cleanup for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. `openclaw sessions cleanup --enforce` still applies the configured cap immediately. +Normal Gateway writes batch `maxEntries` cleanup for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately. OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The legacy `session.maintenance.rotateBytes` key is ignored and `openclaw doctor --fix` removes it from older configs. diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 33624e6b8dc..85a3e168bf5 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -22,6 +22,7 @@ import { normalizeSessionRuntimeModelFields, type SessionEntry } from "./types.j export type LoadSessionStoreOptions = { skipCache?: boolean; maintenanceConfig?: ResolvedSessionMaintenanceConfig; + runMaintenance?: boolean; clone?: boolean; }; @@ -131,28 +132,30 @@ export function loadSessionStore( if (migrated || normalized) { serializedFromDisk = undefined; } - const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig(); - const beforeCount = Object.keys(store).length; - if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) { - const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false }); - const countAfterPrune = Object.keys(store).length; - const capped = shouldRunSessionEntryMaintenance({ - entryCount: countAfterPrune, - maxEntries: maintenance.maxEntries, - }) - ? capEntryCount(store, maintenance.maxEntries, { log: false }) - : 0; - const afterCount = Object.keys(store).length; - if (pruned > 0 || capped > 0) { - serializedFromDisk = undefined; - log.info("applied load-time maintenance to oversized session store", { - storePath, - before: beforeCount, - after: afterCount, - pruned, - capped, + if (opts.runMaintenance) { + const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig(); + const beforeCount = Object.keys(store).length; + if (maintenance.mode === "enforce" && beforeCount > maintenance.maxEntries) { + const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false }); + const countAfterPrune = Object.keys(store).length; + const capped = shouldRunSessionEntryMaintenance({ + entryCount: countAfterPrune, maxEntries: maintenance.maxEntries, - }); + }) + ? capEntryCount(store, maintenance.maxEntries, { log: false }) + : 0; + const afterCount = Object.keys(store).length; + if (pruned > 0 || capped > 0) { + serializedFromDisk = undefined; + log.info("applied load-time maintenance to oversized session store", { + storePath, + before: beforeCount, + after: afterCount, + pruned, + capped, + maxEntries: maintenance.maxEntries, + }); + } } } diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index f0009906a70..aacf25ecdac 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -301,7 +301,7 @@ describe("Integration: saveSessionStore with pruning", () => { expect(Object.keys(loaded)).toHaveLength(2); }); - it("loadSessionStore prunes stale entries from oversized stores by default", async () => { + it("loadSessionStore leaves oversized stores untouched during normal reads", async () => { const now = Date.now(); const store: Record = { stale: makeEntry(now - 31 * DAY_MS), @@ -319,12 +319,37 @@ describe("Integration: saveSessionStore with pruning", () => { }, }); - expect(loaded.stale).toBeUndefined(); + expect(Object.keys(loaded)).toHaveLength(3); + expect(loaded.stale).toBeDefined(); expect(loaded.recent).toBeDefined(); expect(loaded.newest).toBeDefined(); }); - it("loadSessionStore caps oversized stores by default", async () => { + it("loadSessionStore applies maintenance only when explicitly requested", async () => { + const now = Date.now(); + const store: Record = { + stale: makeEntry(now - 31 * DAY_MS), + recent: makeEntry(now - DAY_MS), + newest: makeEntry(now), + }; + await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); + + const loaded = loadSessionStore(storePath, { + skipCache: true, + runMaintenance: true, + maintenanceConfig: { + ...ENFORCED_MAINTENANCE_OVERRIDE, + maxEntries: 1, + pruneAfterMs: 7 * DAY_MS, + }, + }); + + expect(loaded.stale).toBeUndefined(); + expect(loaded.recent).toBeUndefined(); + expect(loaded.newest).toBeDefined(); + }); + + it("loadSessionStore does not cap oversized stores during normal reads", async () => { const now = Date.now(); const store: Record = { oldest: makeEntry(now - 3 * DAY_MS), @@ -342,13 +367,13 @@ describe("Integration: saveSessionStore with pruning", () => { }, }); - expect(Object.keys(loaded)).toHaveLength(2); - expect(loaded.oldest).toBeUndefined(); + expect(Object.keys(loaded)).toHaveLength(3); + expect(loaded.oldest).toBeDefined(); expect(loaded.recent).toBeDefined(); expect(loaded.newest).toBeDefined(); }); - it("loadSessionStore batches entry-count cleanup until the high-water mark", async () => { + it("explicit loadSessionStore maintenance batches entry-count cleanup until the high-water mark", async () => { const now = Date.now(); const store = Object.fromEntries( Array.from({ length: 51 }, (_, index) => [`session-${index}`, makeEntry(now - index)]), @@ -357,6 +382,7 @@ describe("Integration: saveSessionStore with pruning", () => { const loaded = loadSessionStore(storePath, { skipCache: true, + runMaintenance: true, maintenanceConfig: { ...ENFORCED_MAINTENANCE_OVERRIDE, maxEntries: 50, @@ -367,7 +393,7 @@ describe("Integration: saveSessionStore with pruning", () => { expect(Object.keys(loaded)).toHaveLength(51); }); - it("loadSessionStore caps production-sized stores once they reach the high-water mark", async () => { + it("explicit loadSessionStore maintenance caps production-sized stores once they reach the high-water mark", async () => { const now = Date.now(); const store = Object.fromEntries( Array.from({ length: 75 }, (_, index) => [`session-${index}`, makeEntry(now - index)]), @@ -376,6 +402,7 @@ describe("Integration: saveSessionStore with pruning", () => { const loaded = loadSessionStore(storePath, { skipCache: true, + runMaintenance: true, maintenanceConfig: { ...ENFORCED_MAINTENANCE_OVERRIDE, maxEntries: 50,