From 8dedb0ebcaef2bbe6dcd7b9ad137554b62ac1d21 Mon Sep 17 00:00:00 2001 From: sunlit-deng Date: Fri, 10 Jul 2026 05:21:11 +0800 Subject: [PATCH] fix(infra): cap session-maintenance-warning dedupe cache with LRU eviction (#101643) * fix(infra): cap session-maintenance-warning dedupe cache with LRU eviction The warnedContexts Map accumulated every warned session key forever with no eviction, TTL, or size cap. A long-running gateway would grow this unboundedly. Add a 4 096-entry LRU cache with touch-on-read so frequently re-warned sessions survive and old entries are evicted on overflow. * fix(test): restore original unicode escapes and add LRU eviction tests * fix(test): seed eviction entries with real warning context keys ClawSweeper P3: the eviction test seeded but the production buildWarningContext computes . The mismatch meant params1 could redeliver because the context changed, not because LRU eviction actually worked. Seed the exact context pattern so the test fails when eviction breaks. * fix(infra): bound maintenance warning cache Co-authored-by: sunlit-deng --------- Co-authored-by: Peter Steinberger Co-authored-by: sunlit-deng --- src/infra/session-maintenance-warning.test.ts | 61 ++++++++++++++++++- src/infra/session-maintenance-warning.ts | 22 +++++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/infra/session-maintenance-warning.test.ts b/src/infra/session-maintenance-warning.test.ts index 4847c3ef05f5..2e072f9de9fa 100644 --- a/src/infra/session-maintenance-warning.test.ts +++ b/src/infra/session-maintenance-warning.test.ts @@ -105,7 +105,13 @@ describe("deliverSessionMaintenanceWarning", () => { process.env.NODE_ENV = "development"; resetSessionMaintenanceWarningForTests(); mocks.resolveSessionAgentId.mockClear(); - mocks.deliveryContextFromSession.mockClear(); + mocks.deliveryContextFromSession.mockReset(); + mocks.deliveryContextFromSession.mockReturnValue({ + channel: "mobilechat", + to: "+15550001", + accountId: "acct-1", + threadId: "thread-1", + }); mocks.normalizeMessageChannel.mockClear(); mocks.isDeliverableMessageChannel.mockClear(); mocks.deliverOutboundPayloads.mockClear(); @@ -235,4 +241,57 @@ describe("deliverSessionMaintenanceWarning", () => { expect(firstSystemEventCall()?.[0]).toContain(`older than ${expected}`); }); + + it("keeps a recently used context while evicting the least-recently-used entry", async () => { + const maxEntries = 4096; + const createSessionParams = (sessionKey: string) => + createParams({ + sessionKey, + warning: { + activeSessionKey: sessionKey, + pruneAfterMs: 1_000, + maxEntries: 100, + wouldPrune: true, + wouldCap: false, + } as never, + }); + mocks.deliveryContextFromSession.mockReturnValue(undefined as never); + + for (let i = 0; i < maxEntries; i++) { + await deliverSessionMaintenanceWarning(createSessionParams(`session:${i}`)); + } + expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4096); + + // A duplicate read promotes session:0 from the LRU head without re-delivering. + await deliverSessionMaintenanceWarning(createSessionParams("session:0")); + expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4096); + + // Overflow evicts session:1 instead of the recently used session:0. + await deliverSessionMaintenanceWarning(createSessionParams("session:extra")); + expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4097); + await deliverSessionMaintenanceWarning(createSessionParams("session:0")); + expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4097); + await deliverSessionMaintenanceWarning(createSessionParams("session:1")); + expect(mocks.enqueueSystemEvent).toHaveBeenCalledTimes(4098); + }); + + it("re-delivers when the warning context changes for the same session", async () => { + const sessionKey = `agent:${randomUUID()}:main`; + const params = createParams({ sessionKey }); + await deliverSessionMaintenanceWarning(params); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1); + + const changedParams = createParams({ + sessionKey, + warning: { + activeSessionKey: sessionKey, + pruneAfterMs: 86_400_000, + maxEntries: 500, + wouldPrune: true, + wouldCap: true, + } as never, + }); + await deliverSessionMaintenanceWarning(changedParams); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index 3d37062ae9d3..0fb1b994bf14 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { createLazyPromiseLoader } from "../shared/lazy-runtime.js"; import { deliveryContextFromSession } from "../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; +import { pruneMapToMaxSize } from "./map-size.js"; import { buildOutboundSessionContext } from "./outbound/session-context.js"; import { enqueueSystemEvent } from "./system-events.js"; @@ -18,7 +19,21 @@ type WarningParams = { warning: SessionMaintenanceWarning; }; +// Bound process-lifetime dedupe while keeping several agents' default 500-session +// windows resident. Eviction can re-emit one warning for an old session. +const MAX_WARNED_CONTEXTS = 4096; const warnedContexts = new Map(); + +function shouldSuppressWarning(sessionKey: string, contextKey: string): boolean { + const duplicate = warnedContexts.get(sessionKey) === contextKey; + // Refresh insertion order even for suppressed duplicates; otherwise active sessions + // become eviction candidates and can receive repeated warnings under key churn. + warnedContexts.delete(sessionKey); + warnedContexts.set(sessionKey, contextKey); + pruneMapToMaxSize(warnedContexts, MAX_WARNED_CONTEXTS); + return duplicate; +} + const log = createSubsystemLogger("session-maintenance-warning"); const messageRuntimeLoader = createLazyPromiseLoader( () => import("../channels/message/runtime.js"), @@ -111,12 +126,11 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P } const contextKey = buildWarningContext(params); - if (warnedContexts.get(params.sessionKey) === contextKey) { - return; - } // Dedupe by effective warning context so repeated maintenance scans do not // spam the same session, but changed limits still produce a fresh warning. - warnedContexts.set(params.sessionKey, contextKey); + if (shouldSuppressWarning(params.sessionKey, contextKey)) { + return; + } const text = buildWarningText(params.warning); const target = resolveWarningDeliveryTarget(params.entry);