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 <sunlit-deng@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: sunlit-deng <sunlit-deng@users.noreply.github.com>
This commit is contained in:
sunlit-deng
2026-07-10 05:21:11 +08:00
committed by GitHub
parent 4aa8bf12e3
commit 8dedb0ebca
2 changed files with 78 additions and 5 deletions
+60 -1
View File
@@ -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);
});
});
+18 -4
View File
@@ -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<string, string>();
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);