From d347cd10974726281baaec98f7935a46c3fdd609 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 19:33:13 -0700 Subject: [PATCH] refactor: canonicalize cache mechanics (#118262) * refactor: adopt shared map pruning * refactor: share config-scoped ttl cache * refactor: share backup link cache * refactor: centralize native relay retention cap * chore: remove obsolete bundled channel lint suppression * chore: shrink max-lines baseline * refactor: remove cache pruning wrappers --- config/max-lines-baseline.txt | 1 - ...gent-tools.before-tool-call.diagnostics.ts | 16 +--- .../agent-tools.before-tool-call.wrapper.ts | 8 +- src/agents/bootstrap-cache.ts | 15 +--- .../prompt-cache-observability.ts | 6 +- .../run/attempt.prompt-helpers.ts | 6 +- .../session-prompt-state.ts | 9 +-- .../harness/native-hook-relay-permissions.ts | 15 ++-- src/agents/harness/native-hook-relay-state.ts | 1 + src/agents/harness/native-hook-relay.ts | 6 +- src/agents/model-fallback-candidates.ts | 9 +-- src/agents/project-memory-scope.ts | 16 +--- src/agents/session-activity-notes.ts | 9 +-- src/agents/shell-utils.ts | 8 +- src/agents/system-prompt.ts | 9 +-- src/agents/tool-search-catalog.ts | 9 +-- src/agents/tools/web-shared.ts | 8 +- src/audit/agent-event-audit.ts | 9 +-- src/auto-reply/reply/reply-usage-state.ts | 9 +-- src/channels/message/outbound-echo.ts | 9 +-- src/channels/plugins/bundled.ts | 49 ++++--------- src/channels/plugins/read-only.ts | 14 ++-- src/config/schema.ts | 8 +- .../session-accessor.sqlite-message-cut.ts | 8 +- .../session-accessor.sqlite-reset-window.ts | 9 +-- src/config/sessions/skill-prompt-blobs.ts | 17 +---- src/cron/schedule.ts | 11 +-- src/cron/service/jobs-scheduling.ts | 11 +-- src/cron/store.ts | 8 +- src/cron/trigger-script.ts | 12 +-- src/fleet/backup.runtime.ts | 15 +--- src/gateway/assistant-avatar-cache.ts | 9 +-- src/gateway/control-plane-rate-limit.ts | 6 +- src/gateway/control-ui-github-preview.ts | 9 +-- .../control-ui-session-prs-local-git.ts | 9 +-- src/gateway/control-ui-session-prs.ts | 9 +-- src/gateway/control-ui-static.ts | 9 +-- src/gateway/cron-stream-watchers.ts | 9 +-- src/gateway/managed-image-attachments.ts | 15 ++-- src/gateway/mcp-http.runtime.test.ts | 26 ++++++- src/gateway/mcp-http.runtime.ts | 28 ++----- src/gateway/message-action-turn-capability.ts | 16 +--- src/gateway/node-pairing-ssh-verify.ts | 9 +-- src/gateway/openresponses-http.ts | 13 +--- src/gateway/plugin-icon-http.ts | 9 +-- src/gateway/server-channels.ts | 4 +- src/gateway/server-maintenance.ts | 13 +--- .../chat-startup-projection-memo.ts | 9 +-- src/gateway/server-methods/nodes.invoke.ts | 9 +-- src/gateway/server-methods/session-catalog.ts | 9 +-- .../server-methods/session-typing-state.ts | 5 +- src/gateway/server-methods/sessions-files.ts | 9 +-- src/gateway/server-methods/tools-effective.ts | 25 +------ src/gateway/server-node-events.ts | 25 +------ src/gateway/server-session-key.ts | 6 +- src/gateway/server/hooks-request-handler.ts | 9 +-- .../handshake-auth-log-limiter.ts | 13 +--- src/gateway/session-observer-model.ts | 17 +---- src/gateway/session-transcript-files.fs.ts | 5 +- .../session-transcript-title-reader.ts | 9 +-- src/gateway/session-utils-core.ts | 9 +-- src/gateway/session-utils.fs.ts | 5 +- src/gateway/watch-node-http.ts | 9 +-- .../worker-environments/live-events.ts | 8 +- src/infra/backup-create.ts | 19 ++--- src/infra/backup-volatile-stat-cache.ts | 17 +++++ src/infra/device-identity.ts | 8 +- src/infra/executable-path.ts | 9 +-- src/infra/kysely-sync.ts | 8 +- .../outbound/channel-bootstrap.runtime.ts | 8 +- src/infra/outbound/directory-cache.test.ts | 18 +++-- src/infra/outbound/directory-cache.ts | 73 +++++++++---------- src/infra/outbound/target-resolver.ts | 10 ++- src/logging/secret-redaction-registry.ts | 8 +- src/media-understanding/defaults.ts | 6 +- src/media/playback-transcode.ts | 21 ++---- src/plugin-sdk/approval-reaction-runtime.ts | 9 +-- src/plugin-sdk/provider-catalog-shared.ts | 8 +- src/plugins/manifest-registry-installed.ts | 13 +--- src/plugins/plugin-cache-primitives.ts | 15 +--- src/plugins/tool-descriptor-cache.ts | 9 ++- src/routing/account-id.ts | 9 +-- src/security/safe-regex.ts | 8 +- src/sessions/session-key-utils.ts | 9 +-- src/skills/runtime/session-snapshot.ts | 8 +- src/skills/security/scanner.ts | 15 +--- src/utils/usage-format.ts | 8 +- 87 files changed, 306 insertions(+), 724 deletions(-) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index d0e3c6c0b786..b41ffa4633e7 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -532,7 +532,6 @@ src/auto-reply/reply/session.ts src/auto-reply/status.test.ts src/channels/message/ingress-queue.ts src/channels/plugins/bundled.shape-guard.test.ts -src/channels/plugins/bundled.ts src/channels/plugins/message-actions.security.test.ts src/channels/plugins/outbound/interactive.test.ts src/channels/plugins/read-only.test.ts diff --git a/src/agents/agent-tools.before-tool-call.diagnostics.ts b/src/agents/agent-tools.before-tool-call.diagnostics.ts index 619bd0dd4173..562d4ebd27fb 100644 --- a/src/agents/agent-tools.before-tool-call.diagnostics.ts +++ b/src/agents/agent-tools.before-tool-call.diagnostics.ts @@ -25,6 +25,7 @@ import { freezeDiagnosticTraceContext, type DiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { SessionState } from "../logging/diagnostic-session-state.js"; import { redactToolDetail } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -118,13 +119,7 @@ export function rememberPendingTerminalPresentation(params: { toolParams: structuredClone(params.toolParams), toolCallOrdinal: params.toolCallOrdinal, }); - while (pendingTerminalPresentationByToolCall.size > MAX_PENDING_TERMINAL_PRESENTATIONS) { - const oldestKey = pendingTerminalPresentationByToolCall.keys().next().value; - if (!oldestKey) { - break; - } - pendingTerminalPresentationByToolCall.delete(oldestKey); - } + pruneMapToMaxSize(pendingTerminalPresentationByToolCall, MAX_PENDING_TERMINAL_PRESENTATIONS); } /** Finalizes a trusted terminal summary after harness result middleware. */ @@ -581,12 +576,7 @@ export function shouldEmitLoopWarning( return false; } state.toolLoopWarningBuckets.set(warningKey, bucket); - if (state.toolLoopWarningBuckets.size > MAX_LOOP_WARNING_KEYS) { - const oldest = state.toolLoopWarningBuckets.keys().next().value; - if (oldest) { - state.toolLoopWarningBuckets.delete(oldest); - } - } + pruneMapToMaxSize(state.toolLoopWarningBuckets, MAX_LOOP_WARNING_KEYS); return true; } diff --git a/src/agents/agent-tools.before-tool-call.wrapper.ts b/src/agents/agent-tools.before-tool-call.wrapper.ts index a70fdf7e39ba..23721e2885b0 100644 --- a/src/agents/agent-tools.before-tool-call.wrapper.ts +++ b/src/agents/agent-tools.before-tool-call.wrapper.ts @@ -12,6 +12,7 @@ import { createChildDiagnosticTraceContext, freezeDiagnosticTraceContext, } from "../infra/diagnostic-trace-context.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { copyPluginToolMeta, getPluginToolMeta } from "../plugins/tools.js"; import { buildToolContentPrivateData, @@ -188,12 +189,7 @@ export function recordAdjustedParamsForToolCall( } const adjustedParamsKey = buildAdjustedParamsKey({ runId, toolCallId }); adjustedParamsByToolCallId.set(adjustedParamsKey, cloneResult.value); - if (adjustedParamsByToolCallId.size > MAX_TRACKED_ADJUSTED_PARAMS) { - const oldest = adjustedParamsByToolCallId.keys().next().value; - if (oldest) { - adjustedParamsByToolCallId.delete(oldest); - } - } + pruneMapToMaxSize(adjustedParamsByToolCallId, MAX_TRACKED_ADJUSTED_PARAMS); } function cloneParamsForAdjustedReplay( diff --git a/src/agents/bootstrap-cache.ts b/src/agents/bootstrap-cache.ts index 58d3cd92c2b3..a6b24e62e6c4 100644 --- a/src/agents/bootstrap-cache.ts +++ b/src/agents/bootstrap-cache.ts @@ -3,6 +3,7 @@ * Reuses unchanged bootstrap file arrays while refreshing each turn so edits * become visible to long-lived agent sessions. */ +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js"; type BootstrapSnapshot = { @@ -33,22 +34,12 @@ function bootstrapFilesEqual( }); } -function pruneOldestBootstrapSnapshots(): void { - while (cache.size > MAX_BOOTSTRAP_SNAPSHOTS) { - const oldestKey = cache.keys().next().value; - if (typeof oldestKey !== "string") { - return; - } - cache.delete(oldestKey); - } -} - /** Load bootstrap files for a session, reusing the prior snapshot when content is unchanged. */ export async function getOrLoadBootstrapFiles(params: { workspaceDir: string; sessionKey: string; }): Promise { - pruneOldestBootstrapSnapshots(); + pruneMapToMaxSize(cache, MAX_BOOTSTRAP_SNAPSHOTS); const existing = cache.get(params.sessionKey); // Refresh per turn so long-lived sessions pick up edits; loadWorkspaceBootstrapFiles // handles unchanged file content through its guarded inode/mtime cache. @@ -64,7 +55,7 @@ export async function getOrLoadBootstrapFiles(params: { } cache.set(params.sessionKey, { workspaceDir: params.workspaceDir, files }); - pruneOldestBootstrapSnapshots(); + pruneMapToMaxSize(cache, MAX_BOOTSTRAP_SNAPSHOTS); return files; } diff --git a/src/agents/embedded-agent-runner/prompt-cache-observability.ts b/src/agents/embedded-agent-runner/prompt-cache-observability.ts index dbdbcca097e5..8045a81eb41b 100644 --- a/src/agents/embedded-agent-runner/prompt-cache-observability.ts +++ b/src/agents/embedded-agent-runner/prompt-cache-observability.ts @@ -8,6 +8,7 @@ import { } from "@openclaw/ai/internal/shared"; import { stableStringify } from "@openclaw/normalization-core"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import type { NormalizedUsage } from "../usage.js"; type PromptCacheChangeCode = @@ -169,10 +170,7 @@ function setTracker(key: string, tracker: PromptCacheTracker): void { if (trackers.has(key)) { trackers.delete(key); } else if (trackers.size >= MAX_TRACKERS) { - const oldestKey = trackers.keys().next().value; - if (typeof oldestKey === "string") { - trackers.delete(oldestKey); - } + pruneMapToMaxSize(trackers, MAX_TRACKERS - 1); } trackers.set(key, tracker); } diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index 2f750dc7062e..5b80abba3d5a 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -8,6 +8,7 @@ import type { ContextEngineRuntimeContext, ContextEngineSessionTarget, } from "../../../context-engine/types.js"; +import { pruneMapToMaxSize } from "../../../infra/map-size.js"; import { drainPluginNextTurnInjectionContext } from "../../../plugins/host-hook-state.js"; import { buildPluginAgentTurnPrepareContext } from "../../../plugins/host-hooks.js"; import type { @@ -72,10 +73,7 @@ function rememberDrainedInjections( if (promptBuildDrainCache.has(runId)) { promptBuildDrainCache.delete(runId); } else if (promptBuildDrainCache.size >= PROMPT_BUILD_DRAIN_CACHE_MAX) { - const oldest = promptBuildDrainCache.keys().next().value; - if (oldest !== undefined) { - promptBuildDrainCache.delete(oldest); - } + pruneMapToMaxSize(promptBuildDrainCache, PROMPT_BUILD_DRAIN_CACHE_MAX - 1); } promptBuildDrainCache.set(runId, injections); } diff --git a/src/agents/embedded-agent-runner/session-prompt-state.ts b/src/agents/embedded-agent-runner/session-prompt-state.ts index df7cc74bb349..601fc89aad96 100644 --- a/src/agents/embedded-agent-runner/session-prompt-state.ts +++ b/src/agents/embedded-agent-runner/session-prompt-state.ts @@ -1,4 +1,5 @@ /** Process-local prompt projection state owned by an embedded session lifecycle. */ +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; import type { AgentMessage } from "../runtime/index.js"; @@ -56,13 +57,7 @@ export function getEmbeddedSessionPromptState(sessionId: string): EmbeddedSessio } const created = createSessionPromptState(); sessionPromptStates.set(sessionId, created); - while (sessionPromptStates.size > MAX_SESSION_PROMPT_STATES) { - const oldest = sessionPromptStates.keys().next().value; - if (typeof oldest !== "string") { - break; - } - sessionPromptStates.delete(oldest); - } + pruneMapToMaxSize(sessionPromptStates, MAX_SESSION_PROMPT_STATES); return created; } diff --git a/src/agents/harness/native-hook-relay-permissions.ts b/src/agents/harness/native-hook-relay-permissions.ts index 78c003c5bf18..c19e42c12509 100644 --- a/src/agents/harness/native-hook-relay-permissions.ts +++ b/src/agents/harness/native-hook-relay-permissions.ts @@ -6,6 +6,7 @@ import { import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js"; import { isApprovalNotFoundError } from "../../infra/approval-errors.js"; import { toErrorObject } from "../../infra/errors.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { PluginApprovalResolutions } from "../../plugins/types.js"; import { @@ -18,7 +19,10 @@ import { nativeHookRelayParamsWereRewritten, normalizeNativeHookToolName, } from "./native-hook-relay-codec.js"; -import { nativeHookRelayState } from "./native-hook-relay-state.js"; +import { + MAX_NATIVE_HOOK_RELAY_INVOCATIONS, + nativeHookRelayState, +} from "./native-hook-relay-state.js"; import type { JsonValue, NativeHookRelayDeferredApprovalOutcome, @@ -38,7 +42,6 @@ export type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferre const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; const PERMISSION_ALLOW_ALWAYS_TTL_MS = 30 * 60 * 1000; -const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200; const MAX_PERMISSION_FALLBACK_KEYS = 200; const MAX_PERMISSION_FALLBACK_KEY_CHARS = 240; const MAX_PERMISSION_FINGERPRINT_SORT_KEYS = 200; @@ -445,13 +448,7 @@ function rememberNativeHookRelayPermissionAllowAlways(key: string, now = Date.no return; } permissionAllowAlwaysApprovals.set(key, { expiresAtMs }); - while (permissionAllowAlwaysApprovals.size > MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES) { - const oldestKey = permissionAllowAlwaysApprovals.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - permissionAllowAlwaysApprovals.delete(oldestKey); - } + pruneMapToMaxSize(permissionAllowAlwaysApprovals, MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES); } export function pruneNativeHookRelayPermissionAllowAlways(now = Date.now()): void { diff --git a/src/agents/harness/native-hook-relay-state.ts b/src/agents/harness/native-hook-relay-state.ts index dd2c24f0c40e..5d0f0a5f22ca 100644 --- a/src/agents/harness/native-hook-relay-state.ts +++ b/src/agents/harness/native-hook-relay-state.ts @@ -1,6 +1,7 @@ import type { NativeHookRelaySharedState } from "./native-hook-relay-types.js"; const NATIVE_HOOK_RELAY_STATE_SYMBOL = Symbol.for("openclaw.nativeHookRelay.state"); +export const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200; function getNativeHookRelaySharedState(): NativeHookRelaySharedState { const globalRecord = globalThis as typeof globalThis & { diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index acc7b3822d9a..0dc0249f35a9 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -42,7 +42,10 @@ import { setNativeHookRelayPermissionApprovalRequesterForTests as setNativeHookRelayPermissionApprovalRequesterForTestsImpl, } from "./native-hook-relay-permissions.js"; import type { NativeHookRelayDeferredToolApprovalRequester } from "./native-hook-relay-permissions.js"; -import { nativeHookRelayState } from "./native-hook-relay-state.js"; +import { + MAX_NATIVE_HOOK_RELAY_INVOCATIONS, + nativeHookRelayState, +} from "./native-hook-relay-state.js"; import type { ActiveNativeHookRelayRegistration, ActiveNativeHookRelayRegistrationHandle, @@ -75,7 +78,6 @@ export type { } from "./native-hook-relay-types.js"; const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000; -const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200; const log = createSubsystemLogger("agents/harness/native-hook-relay"); const { relays, relayBridges, invocations } = nativeHookRelayState; diff --git a/src/agents/model-fallback-candidates.ts b/src/agents/model-fallback-candidates.ts index 4f393d1c51a5..49ba9e608e60 100644 --- a/src/agents/model-fallback-candidates.ts +++ b/src/agents/model-fallback-candidates.ts @@ -5,6 +5,7 @@ import { resolveAgentModelPrimaryValue, } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; @@ -158,13 +159,7 @@ export function resolveModelCandidateChain( const candidates = resolveFallbackCandidatesUncached(params); if (cacheKey) { fallbackCandidateCache.set(cacheKey, candidates.map(cloneModelCandidate)); - while (fallbackCandidateCache.size > MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES) { - const oldest = fallbackCandidateCache.keys().next(); - if (oldest.done) { - break; - } - fallbackCandidateCache.delete(oldest.value); - } + pruneMapToMaxSize(fallbackCandidateCache, MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES); } return candidates; } diff --git a/src/agents/project-memory-scope.ts b/src/agents/project-memory-scope.ts index c3e4557235d0..9ef1a04c0bc8 100644 --- a/src/agents/project-memory-scope.ts +++ b/src/agents/project-memory-scope.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { parseGitUrl } from "./utils/git.js"; @@ -18,18 +19,6 @@ function escapeProjectKeyForAnnotation(value: string): string { .replaceAll("\n", "%0a"); } -function setBounded(map: Map, key: K, value: V, limit: number): void { - map.delete(key); - map.set(key, value); - while (map.size > limit) { - const oldest = map.keys().next().value as K | undefined; - if (oldest === undefined) { - break; - } - map.delete(oldest); - } -} - async function resolveUncachedProjectKey(repoRoot: string): Promise { try { const result = await runCommandWithTimeout( @@ -64,6 +53,7 @@ export function resolveProjectKey(repoRoot: string): Promise { return cached; } const pending = resolveUncachedProjectKey(canonicalRoot); - setBounded(projectKeyByRepoRoot, canonicalRoot, pending, MAX_PROJECT_KEY_CACHE_ENTRIES); + projectKeyByRepoRoot.set(canonicalRoot, pending); + pruneMapToMaxSize(projectKeyByRepoRoot, MAX_PROJECT_KEY_CACHE_ENTRIES); return pending; } diff --git a/src/agents/session-activity-notes.ts b/src/agents/session-activity-notes.ts index d97a123884e6..eb303cd676ac 100644 --- a/src/agents/session-activity-notes.ts +++ b/src/agents/session-activity-notes.ts @@ -3,6 +3,7 @@ import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../auto-reply/heartbeat.js"; import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import { normalizeAgentPlanSteps } from "../channels/streaming.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { redactToolPayloadText } from "../logging/redact.js"; import { buildAgentRunTerminalOutcomeFromLifecycleEvent, @@ -167,13 +168,7 @@ function rememberItemStatus( } state.itemStatuses.delete(itemId); state.itemStatuses.set(itemId, status); - while (state.itemStatuses.size > limit) { - const oldest = state.itemStatuses.keys().next().value; - if (oldest === undefined) { - break; - } - state.itemStatuses.delete(oldest); - } + pruneMapToMaxSize(state.itemStatuses, limit); return true; } diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index 078411ca2574..6a3e8355c64d 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -8,6 +8,7 @@ import fs from "node:fs"; import path from "node:path"; import { AnsiSequenceStripper } from "../../packages/terminal-core/src/ansi-sequences.js"; import { stripAnsiForStreamChunk } from "../../packages/terminal-core/src/ansi.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { killProcessTree as killProcessTreeGracefully, type KillProcessTreeOptions, @@ -133,12 +134,7 @@ function resolveWindowsGitBashUsrBin(shellPath: string): string | undefined { fs.existsSync(usrBin) ? usrBin : undefined; - if (windowsGitBashUsrBinCache.size >= WINDOWS_GIT_BASH_CACHE_LIMIT) { - const oldestKey = windowsGitBashUsrBinCache.keys().next().value; - if (oldestKey) { - windowsGitBashUsrBinCache.delete(oldestKey); - } - } + pruneMapToMaxSize(windowsGitBashUsrBinCache, WINDOWS_GIT_BASH_CACHE_LIMIT - 1); windowsGitBashUsrBinCache.set(cacheKey, resolved); return resolved; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a8522c9802d8..11a2eced75ff 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -28,6 +28,7 @@ import { } from "../channels/plugins/native-approval-prompt.js"; import type { SubagentDelegationMode } from "../config/types.agent-defaults.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { buildMemoryPromptSection, type PreparedMemoryPromptSection, @@ -159,13 +160,7 @@ function cacheStablePromptPrefix(key: string, build: () => string): string { const value = build(); stablePromptPrefixCache.set(key, { value }); - while (stablePromptPrefixCache.size > SYSTEM_PROMPT_STABLE_PREFIX_CACHE_LIMIT) { - const oldestKey = stablePromptPrefixCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - stablePromptPrefixCache.delete(oldestKey); - } + pruneMapToMaxSize(stablePromptPrefixCache, SYSTEM_PROMPT_STABLE_PREFIX_CACHE_LIMIT); return value; } diff --git a/src/agents/tool-search-catalog.ts b/src/agents/tool-search-catalog.ts index c89b2c23daf9..1484a22acb68 100644 --- a/src/agents/tool-search-catalog.ts +++ b/src/agents/tool-search-catalog.ts @@ -1,3 +1,4 @@ +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { generateSecureToken } from "../infra/secure-random.js"; import { getPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js"; import type { HookContext } from "./agent-tools.before-tool-call.js"; @@ -153,13 +154,7 @@ function rememberReusableCatalog(key: string | undefined, catalog: ToolSearchCat reusableCatalogSnapshots.delete(key); } reusableCatalogSnapshots.set(key, { entries: catalog.entries, fingerprint }); - while (reusableCatalogSnapshots.size > MAX_REUSABLE_CATALOG_SNAPSHOTS) { - const oldestKey = reusableCatalogSnapshots.keys().next().value; - if (!oldestKey) { - break; - } - reusableCatalogSnapshots.delete(oldestKey); - } + pruneMapToMaxSize(reusableCatalogSnapshots, MAX_REUSABLE_CATALOG_SNAPSHOTS); } function classifyTool(tool: CatalogTool): { diff --git a/src/agents/tools/web-shared.ts b/src/agents/tools/web-shared.ts index d5d92ad2f95e..eba88e51fa14 100644 --- a/src/agents/tools/web-shared.ts +++ b/src/agents/tools/web-shared.ts @@ -9,6 +9,7 @@ import { MAX_TIMER_TIMEOUT_SECONDS, resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; export type CacheEntry = { value: T; expiresAt: number; @@ -72,12 +73,7 @@ export function writeCache( if (expiresAt === undefined) { return; } - if (cache.size >= DEFAULT_CACHE_MAX_ENTRIES) { - const oldest = cache.keys().next(); - if (!oldest.done) { - cache.delete(oldest.value); - } - } + pruneMapToMaxSize(cache, DEFAULT_CACHE_MAX_ENTRIES - 1); cache.set(key, { value, expiresAt, diff --git a/src/audit/agent-event-audit.ts b/src/audit/agent-event-audit.ts index 6722446f6234..265e047ff7b2 100644 --- a/src/audit/agent-event-audit.ts +++ b/src/audit/agent-event-audit.ts @@ -12,6 +12,7 @@ import { import { isAllowedToolCallName } from "../agents/tool-call-shared.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; import type { TrustedToolExecutionEvent } from "../infra/diagnostic-events.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import type { @@ -81,13 +82,7 @@ function rememberRunProvenance( ): void { runProvenance.delete(runId); runProvenance.set(runId, provenance); - while (runProvenance.size > MAX_TRACKED_RUN_PROVENANCE) { - const oldestRunId = runProvenance.keys().next().value; - if (oldestRunId === undefined) { - break; - } - runProvenance.delete(oldestRunId); - } + pruneMapToMaxSize(runProvenance, MAX_TRACKED_RUN_PROVENANCE); } function resolveProvenance( diff --git a/src/auto-reply/reply/reply-usage-state.ts b/src/auto-reply/reply/reply-usage-state.ts index 0ccf8fee82ea..a4bf7ae70b46 100644 --- a/src/auto-reply/reply/reply-usage-state.ts +++ b/src/auto-reply/reply/reply-usage-state.ts @@ -1,6 +1,7 @@ import { resolveAgentIdentity } from "../../agents/identity.js"; import { deriveContextPromptTokens, type NormalizedUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js"; import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; @@ -113,13 +114,7 @@ function prune(now: number): void { } // This handoff is best-effort metadata for an optional hook. Bound bursts so // completed runs cannot retain one full snapshot each for the whole TTL. - while (store.size > MAX_REPLY_USAGE_STATE_ENTRIES) { - const oldest = store.keys().next(); - if (oldest.done) { - return; - } - store.delete(oldest.value); - } + pruneMapToMaxSize(store, MAX_REPLY_USAGE_STATE_ENTRIES); } export function recordReplyUsageState( diff --git a/src/channels/message/outbound-echo.ts b/src/channels/message/outbound-echo.ts index 1ff1224d7576..79013f810f67 100644 --- a/src/channels/message/outbound-echo.ts +++ b/src/channels/message/outbound-echo.ts @@ -3,6 +3,7 @@ import { resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { normalizeAccountId } from "../../routing/account-id.js"; import { outboundMessageIdentities } from "./outbound-echo-state.js"; @@ -63,13 +64,7 @@ export function recordOutboundMessageIdentity(identity: OutboundMessageIdentity) pruneExpiredEntries(nowMs); for (const key of keys) { outboundMessageIdentities.delete(key); - while (outboundMessageIdentities.size >= OUTBOUND_MESSAGE_IDENTITY_MAX_ENTRIES) { - const oldest = outboundMessageIdentities.keys().next(); - if (oldest.done) { - break; - } - outboundMessageIdentities.delete(oldest.value); - } + pruneMapToMaxSize(outboundMessageIdentities, OUTBOUND_MESSAGE_IDENTITY_MAX_ENTRIES - 1); outboundMessageIdentities.set(key, expiresAt); } } diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 322207d8eafe..27ddbe6ff522 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { extractErrorCode, formatErrorMessage } from "../../infra/errors.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { @@ -113,23 +114,6 @@ const bundledChannelLoadContextsByRoot = new Map(); const sourceBundledEntryLoaderCache: PluginModuleLoaderCache = new Map(); -function rememberBoundedBundledChannelValue( - cache: Map, - key: TKey, - value: TValue, - maxSize: number, -): TValue { - cache.delete(key); - cache.set(key, value); - if (cache.size > maxSize) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) { - cache.delete(oldestKey); - } - } - return value; -} - function isSourceModulePath(modulePath: string): boolean { return /\.(?:c|m)?tsx?$/iu.test(modulePath); } @@ -201,12 +185,10 @@ function resolveBundledChannelBoundaryRoot(params: { ].join("\0"); const cached = bundledChannelBoundaryRoots.get(cacheKey); if (cached) { - return rememberBoundedBundledChannelValue( - bundledChannelBoundaryRoots, - cacheKey, - cached, - MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, - ); + bundledChannelBoundaryRoots.delete(cacheKey); + bundledChannelBoundaryRoots.set(cacheKey, cached); + pruneMapToMaxSize(bundledChannelBoundaryRoots, MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS); + return cached; } const canonicalModulePath = resolveCanonicalPathOrAbsolute(params.modulePath); const sourceRoot = path.resolve(params.packageRoot, "extensions", params.metadata.dirName); @@ -222,12 +204,9 @@ function resolveBundledChannelBoundaryRoot(params: { .map(resolveCanonicalPathOrAbsolute) .find((root) => isPathInside(root, canonicalModulePath)) ?? resolveCanonicalPathOrAbsolute(sourceRoot); - return rememberBoundedBundledChannelValue( - bundledChannelBoundaryRoots, - cacheKey, - boundaryRoot, - MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, - ); + bundledChannelBoundaryRoots.set(cacheKey, boundaryRoot); + pruneMapToMaxSize(bundledChannelBoundaryRoots, MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS); + return boundaryRoot; } function resolveGeneratedBundledChannelModulePath(params: { @@ -406,12 +385,11 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e loadContext: BundledChannelLoadContext; } { const rootScope = resolveBundledChannelRootScope(env); - const loadContext = rememberBoundedBundledChannelValue( - bundledChannelLoadContextsByRoot, - rootScope.cacheKey, - bundledChannelLoadContextsByRoot.get(rootScope.cacheKey) ?? createBundledChannelLoadContext(), - MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS, - ); + const loadContext = + bundledChannelLoadContextsByRoot.get(rootScope.cacheKey) ?? createBundledChannelLoadContext(); + bundledChannelLoadContextsByRoot.delete(rootScope.cacheKey); + bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, loadContext); + pruneMapToMaxSize(bundledChannelLoadContextsByRoot, MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS); return { rootScope, loadContext, @@ -779,4 +757,3 @@ export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): } setter(runtime); } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 6c9b095ab1e6..0c64aed141cf 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -13,6 +13,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { @@ -105,15 +106,10 @@ function rememberReadOnlyChannelPluginResolution( readOnlyChannelPluginResolutionCache.delete(key); } readOnlyChannelPluginResolutionCache.set(key, cloneReadOnlyChannelPluginResolution(resolution)); - while ( - readOnlyChannelPluginResolutionCache.size > MAX_READ_ONLY_CHANNEL_PLUGIN_RESOLUTION_CACHE_SIZE - ) { - const oldestKey = readOnlyChannelPluginResolutionCache.keys().next().value; - if (!oldestKey) { - break; - } - readOnlyChannelPluginResolutionCache.delete(oldestKey); - } + pruneMapToMaxSize( + readOnlyChannelPluginResolutionCache, + MAX_READ_ONLY_CHANNEL_PLUGIN_RESOLUTION_CACHE_SIZE, + ); } function resolveReadOnlyChannelPluginResolutionCacheKey(params: { diff --git a/src/config/schema.ts b/src/config/schema.ts index 8ca7b1951812..3f3fc969d7b9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { CHANNEL_IDS } from "../channels/ids.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js"; import { computeBaseConfigSchemaResponse } from "./schema-base.js"; @@ -506,12 +507,7 @@ function buildMergedSchemaCacheKey(params: { } function setMergedSchemaCache(key: string, value: ConfigSchemaResponse): void { - if (mergedSchemaCache.size >= MERGED_SCHEMA_CACHE_MAX) { - const oldest = mergedSchemaCache.keys().next(); - if (!oldest.done) { - mergedSchemaCache.delete(oldest.value); - } - } + pruneMapToMaxSize(mergedSchemaCache, MERGED_SCHEMA_CACHE_MAX - 1); mergedSchemaCache.set(key, value); } diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index 606de4b12ab8..e16f72edf370 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { executeSqliteQueryTakeFirstSync } from "../../infra/kysely-sync.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { extractAssistantVisibleText } from "../../shared/chat-message-content.js"; import { openOpenClawAgentDatabase, @@ -122,12 +123,7 @@ function loadSessionBranchSummaries( ); sessionBranchCache.delete(cacheKey); sessionBranchCache.set(cacheKey, { ...watermark, branches }); - if (sessionBranchCache.size > SESSION_BRANCH_CACHE_MAX_ENTRIES) { - const oldestKey = sessionBranchCache.keys().next().value; - if (oldestKey !== undefined) { - sessionBranchCache.delete(oldestKey); - } - } + pruneMapToMaxSize(sessionBranchCache, SESSION_BRANCH_CACHE_MAX_ENTRIES); return cloneSessionBranchSummaries(branches); } diff --git a/src/config/sessions/session-accessor.sqlite-reset-window.ts b/src/config/sessions/session-accessor.sqlite-reset-window.ts index 65c50d83f782..b0e20a3a28a7 100644 --- a/src/config/sessions/session-accessor.sqlite-reset-window.ts +++ b/src/config/sessions/session-accessor.sqlite-reset-window.ts @@ -4,6 +4,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../../infra/kysely-sync.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import type { TranscriptEvent } from "./session-accessor.sqlite-contract.js"; @@ -121,13 +122,7 @@ function readTranscriptGeneration(projection: ResetWindowProjection): string | u function cacheResetMessageWindow(key: string, entry: ResetMessageWindowCacheEntry): void { resetMessageWindowCache.delete(key); resetMessageWindowCache.set(key, entry); - while (resetMessageWindowCache.size > MAX_RESET_MESSAGE_WINDOW_CACHE) { - const oldest = resetMessageWindowCache.keys().next().value; - if (typeof oldest !== "string") { - break; - } - resetMessageWindowCache.delete(oldest); - } + pruneMapToMaxSize(resetMessageWindowCache, MAX_RESET_MESSAGE_WINDOW_CACHE); } function findLatestResetMessageWindow( diff --git a/src/config/sessions/skill-prompt-blobs.ts b/src/config/sessions/skill-prompt-blobs.ts index 55fcebe220a1..b2d0e426556a 100644 --- a/src/config/sessions/skill-prompt-blobs.ts +++ b/src/config/sessions/skill-prompt-blobs.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { writeTextAtomic } from "../../infra/json-files.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import type { SessionEntry, SessionSkillPromptRef, SessionSkillSnapshot } from "./types.js"; const PROMPT_BLOB_DIR = "skills-prompts"; @@ -69,13 +70,7 @@ function buildPromptRef(prompt: string): SessionSkillPromptRef { }; promptRefCache.set(prompt, ref); // Bounded process cache avoids rehashing repeated prompt snapshots without becoming store state. - while (promptRefCache.size > PROMPT_REF_CACHE_MAX_ENTRIES) { - const oldest = promptRefCache.keys().next().value; - if (typeof oldest !== "string") { - break; - } - promptRefCache.delete(oldest); - } + pruneMapToMaxSize(promptRefCache, PROMPT_REF_CACHE_MAX_ENTRIES); return ref; } @@ -88,13 +83,7 @@ function shouldStorePromptAsBlob(prompt: string): boolean { function rememberValidPromptBlob(blobPath: string, stat: fs.Stats, prompt: string): void { validPromptBlobCache.set(blobPath, { mtimeMs: stat.mtimeMs, size: stat.size, prompt }); - while (validPromptBlobCache.size > VALID_PROMPT_BLOB_CACHE_MAX_ENTRIES) { - const oldest = validPromptBlobCache.keys().next().value; - if (typeof oldest !== "string") { - break; - } - validPromptBlobCache.delete(oldest); - } + pruneMapToMaxSize(validPromptBlobCache, VALID_PROMPT_BLOB_CACHE_MAX_ENTRIES); } function readValidPromptBlob(storePath: string, ref: SessionSkillPromptRef): string | null { diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 3857579ae4e7..34357102548b 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -1,6 +1,7 @@ /** Computes at/every/cron schedule timestamps with bounded Croner caching. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { Cron } from "croner"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { parseAbsoluteTimeMs } from "./parse.js"; import { coerceFiniteScheduleNumber } from "./schedule-number.js"; import type { CronSchedule } from "./types.js"; @@ -27,14 +28,8 @@ function resolveCachedCron(expr: string, timezone: string): Cron { cronEvalCache.set(key, cached); return cached; } - if (cronEvalCache.size >= CRON_EVAL_CACHE_MAX) { - // Expression parsing is expensive enough to cache, but cron jobs can be - // edited dynamically; keep the cache bounded and LRU-like. - const oldest = cronEvalCache.keys().next().value; - if (oldest) { - cronEvalCache.delete(oldest); - } - } + // Expression parsing is expensive, so retain the most recently promoted entries. + pruneMapToMaxSize(cronEvalCache, CRON_EVAL_CACHE_MAX - 1); const next = new Cron(expr, { timezone, catch: false }); cronEvalCache.set(key, next); return next; diff --git a/src/cron/service/jobs-scheduling.ts b/src/cron/service/jobs-scheduling.ts index c7c2dbd8ccf0..9c277011a638 100644 --- a/src/cron/service/jobs-scheduling.ts +++ b/src/cron/service/jobs-scheduling.ts @@ -1,6 +1,7 @@ /** Scheduling state and next-run computation for cron jobs. */ import crypto from "node:crypto"; import { expectDefined } from "@openclaw/normalization-core"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { isCronJobActive } from "../active-jobs.js"; import { parseAbsoluteTimeMs } from "../parse.js"; import { @@ -99,14 +100,8 @@ function resolveStableCronOffsetMs(jobId: string, staggerMs: number) { } const digest = crypto.createHash("sha256").update(jobId).digest(); const offset = digest.readUInt32BE(0) % staggerMs; - if (staggerOffsetCache.size >= STAGGER_OFFSET_CACHE_MAX) { - // The offset is deterministic, so the cache can evict oldest entries - // without changing scheduling semantics for future lookups. - const first = staggerOffsetCache.keys().next(); - if (!first.done) { - staggerOffsetCache.delete(first.value); - } - } + // The offset is deterministic, so FIFO eviction does not change future scheduling semantics. + pruneMapToMaxSize(staggerOffsetCache, STAGGER_OFFSET_CACHE_MAX - 1); staggerOffsetCache.set(cacheKey, offset); return offset; } diff --git a/src/cron/store.ts b/src/cron/store.ts index d1b48b0d901a..0d74b77304d9 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { expandHomePrefix } from "../infra/home-dir.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { openOpenClawStateDatabase, @@ -50,12 +51,7 @@ function noteCronJobsStoreCommit(storeKey: string): void { // polling SQLite or discarding the current scheduler's transient run state. cronStoreRevisions.delete(storeKey); cronStoreRevisions.set(storeKey, ++nextCronStoreRevision); - if (cronStoreRevisions.size > MAX_TRACKED_CRON_STORE_REVISIONS) { - const oldestStoreKey = cronStoreRevisions.keys().next().value; - if (oldestStoreKey !== undefined) { - cronStoreRevisions.delete(oldestStoreKey); - } - } + pruneMapToMaxSize(cronStoreRevisions, MAX_TRACKED_CRON_STORE_REVISIONS); } function resolveDefaultCronDir(env: NodeJS.ProcessEnv): string { diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts index 70a7f20cdd3a..5e7f3ec9c210 100644 --- a/src/cron/trigger-script.ts +++ b/src/cron/trigger-script.ts @@ -41,6 +41,7 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { getPluginToolMeta } from "../plugins/tools.js"; @@ -343,15 +344,6 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { // concurrent cold evaluations for one job single-flight. const runtimeCache = new Map(); - const trimRuntimeCache = () => { - while (runtimeCache.size > MAX_CACHED_TRIGGER_RUNTIMES) { - const oldestJobId = runtimeCache.keys().next().value; - if (oldestJobId === undefined) { - return; - } - runtimeCache.delete(oldestJobId); - } - }; const resolveCachedRuntime = async (request: { runtimeConfig: OpenClawConfig; jobId: string; @@ -404,7 +396,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { }; runtimeCache.delete(request.jobId); runtimeCache.set(request.jobId, entry); - trimRuntimeCache(); + pruneMapToMaxSize(runtimeCache, MAX_CACHED_TRIGGER_RUNTIMES); // Failed preparations evict themselves so the next tick retries cold. void promise.catch(() => { if (runtimeCache.get(request.jobId) === entry) { diff --git a/src/fleet/backup.runtime.ts b/src/fleet/backup.runtime.ts index 260563c0ca33..cebf78fb252e 100644 --- a/src/fleet/backup.runtime.ts +++ b/src/fleet/backup.runtime.ts @@ -12,6 +12,7 @@ import { ArchiveSecurityError, extractArchive, } from "../infra/archive.js"; +import { createBackupLinkCache } from "../infra/backup-volatile-stat-cache.js"; import { formatErrorMessage as errorMessage } from "../infra/errors.js"; import { root as fsSafeRoot } from "../infra/fs-safe.js"; import { @@ -49,18 +50,6 @@ const RESTORE_VERIFY_TIMEOUT_MS = 60_000; const RESTORE_VERIFY_POLL_MS = 1_000; const RESTORE_EXTRACT_TIMEOUT_MS = 30 * 60_000; -type BackupLinkCacheKey = `${number}:${number}`; - -class BackupLinkCache extends Map { - override get(_key: BackupLinkCacheKey): undefined { - return undefined; - } - - override set(_key: BackupLinkCacheKey, _value: string): this { - return this; - } -} - type FleetBackupManifest = { schemaVersion: 1; kind: "openclaw-fleet-cell-backup"; @@ -316,7 +305,7 @@ export async function backupFleetCell(params: { gzip: true, portable: true, preservePaths: true, - linkCache: new BackupLinkCache(), + linkCache: createBackupLinkCache(), filter, onWriteEntry: (entry) => { entry.path = remapArchivePath(entry.path, manifestPath, dataTarget, authTarget); diff --git a/src/gateway/assistant-avatar-cache.ts b/src/gateway/assistant-avatar-cache.ts index 3d006446e7a7..ec76bc84e2bf 100644 --- a/src/gateway/assistant-avatar-cache.ts +++ b/src/gateway/assistant-avatar-cache.ts @@ -6,6 +6,7 @@ import { readOpenedLocalAgentAvatarDataUrl, type OpenedLocalAgentAvatarFile, } from "../agents/identity-avatar-file.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; type AvatarDataUrlCacheEntry = { ctimeMs: number; @@ -59,13 +60,7 @@ export function createGatewayAvatarDataUrlCache(params?: { size: opened.stat.size, dataUrl, }); - while (entries.size > maxEntries) { - const oldestPath = entries.keys().next().value; - if (oldestPath === undefined) { - break; - } - entries.delete(oldestPath); - } + pruneMapToMaxSize(entries, maxEntries); return dataUrl; }, }; diff --git a/src/gateway/control-plane-rate-limit.ts b/src/gateway/control-plane-rate-limit.ts index 4629d5664186..2b01bd111fb1 100644 --- a/src/gateway/control-plane-rate-limit.ts +++ b/src/gateway/control-plane-rate-limit.ts @@ -1,5 +1,6 @@ // Control-plane rate limiting bounds write-side RPC attempts per device/IP and // caps bucket growth against unique-key memory pressure. +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { normalizeControlPlaneIdentityPart } from "./control-plane-identity.js"; import type { GatewayClient } from "./server-methods/types.js"; @@ -53,10 +54,7 @@ export function consumeControlPlaneWriteBudget(params: { !controlPlaneBuckets.has(key) && controlPlaneBuckets.size >= CONTROL_PLANE_BUCKET_MAX_ENTRIES ) { - const oldest = controlPlaneBuckets.keys().next().value; - if (oldest !== undefined) { - controlPlaneBuckets.delete(oldest); - } + pruneMapToMaxSize(controlPlaneBuckets, CONTROL_PLANE_BUCKET_MAX_ENTRIES - 1); } controlPlaneBuckets.set(key, { count: 1, diff --git a/src/gateway/control-ui-github-preview.ts b/src/gateway/control-ui-github-preview.ts index dde23dea55bf..b6b2a61f2c2d 100644 --- a/src/gateway/control-ui-github-preview.ts +++ b/src/gateway/control-ui-github-preview.ts @@ -1,3 +1,4 @@ +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { ControlUiGitHubPreview } from "./control-ui-contract.js"; // Same-origin GitHub metadata adapter for Control UI link previews. import { @@ -325,12 +326,6 @@ export function loadControlUiGitHubPreview( }), }; previewCache.set(key, entry); - while (previewCache.size > CACHE_LIMIT) { - const oldestKey = previewCache.keys().next().value as string | undefined; - if (!oldestKey) { - break; - } - previewCache.delete(oldestKey); - } + pruneMapToMaxSize(previewCache, CACHE_LIMIT); return entry.promise; } diff --git a/src/gateway/control-ui-session-prs-local-git.ts b/src/gateway/control-ui-session-prs-local-git.ts index baf015adb4ee..bbeec590a4fe 100644 --- a/src/gateway/control-ui-session-prs-local-git.ts +++ b/src/gateway/control-ui-session-prs-local-git.ts @@ -1,4 +1,5 @@ import { runGit } from "../agents/worktrees/git.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { gitOutput, resolveBranchLanding, @@ -45,13 +46,7 @@ function createLocalGitCache() { const entry = { expiresAt: Date.now() + LOCAL_GIT_CACHE_MS, promise: load() }; entries.delete(key); entries.set(key, entry); - while (entries.size > LOCAL_GIT_CACHE_LIMIT) { - const oldestKey = entries.keys().next().value; - if (oldestKey === undefined) { - break; - } - entries.delete(oldestKey); - } + pruneMapToMaxSize(entries, LOCAL_GIT_CACHE_LIMIT); return entry.promise; }; } diff --git a/src/gateway/control-ui-session-prs.ts b/src/gateway/control-ui-session-prs.ts index 009dc2094419..e0887e0a755c 100644 --- a/src/gateway/control-ui-session-prs.ts +++ b/src/gateway/control-ui-session-prs.ts @@ -5,6 +5,7 @@ import nodePath from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { runGit } from "../agents/worktrees/git.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import type { ControlUiSessionBranch, @@ -669,12 +670,6 @@ async function cachedBranchPullRequests( ); branchCache.delete(key); branchCache.set(key, entry); - while (branchCache.size > CACHE_LIMIT) { - const oldestKey = branchCache.keys().next().value as string | undefined; - if (!oldestKey) { - break; - } - branchCache.delete(oldestKey); - } + pruneMapToMaxSize(branchCache, CACHE_LIMIT); return promise; } diff --git a/src/gateway/control-ui-static.ts b/src/gateway/control-ui-static.ts index f7a9b26c999e..b8454afb143e 100644 --- a/src/gateway/control-ui-static.ts +++ b/src/gateway/control-ui-static.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; const CONTROL_UI_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; @@ -303,13 +304,7 @@ function cachedCompressedControlUiHtml( () => compressControlUiBody(Buffer.from(body), encoding), { cacheRejections: false }, ); - while (controlUiHtmlCompressionCache.size > CONTROL_UI_HTML_COMPRESSION_CACHE_MAX_ENTRIES) { - const oldestKey = controlUiHtmlCompressionCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - controlUiHtmlCompressionCache.delete(oldestKey); - } + pruneMapToMaxSize(controlUiHtmlCompressionCache, CONTROL_UI_HTML_COMPRESSION_CACHE_MAX_ENTRIES); return compression; } diff --git a/src/gateway/cron-stream-watchers.ts b/src/gateway/cron-stream-watchers.ts index 4b455fd0b848..970a8f2b13d8 100644 --- a/src/gateway/cron-stream-watchers.ts +++ b/src/gateway/cron-stream-watchers.ts @@ -1,5 +1,6 @@ import { resolveCronTriggerMinIntervalMs } from "../config/cron-limits.js"; import type { CronJob, CronJobState } from "../cron/types.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { ProcessSupervisor } from "../process/supervisor/index.js"; import { CronStreamJobOwner, @@ -140,13 +141,7 @@ export function createCronStreamWatchers(params: { snapshot.coalescedBatches, ), }); - while (retiredCounterSeeds.size > MAX_RETIRED_COUNTER_SEEDS) { - const oldest = retiredCounterSeeds.keys().next().value; - if (oldest === undefined) { - break; - } - retiredCounterSeeds.delete(oldest); - } + pruneMapToMaxSize(retiredCounterSeeds, MAX_RETIRED_COUNTER_SEEDS); }; const createOwner = (job: CronStreamJob): CronStreamJobOwner => { diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 34f7ffd1dd42..8fb8278a66c3 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -16,6 +16,7 @@ import type { ReplyMediaAttachment } from "../auto-reply/reply-payload.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { openLocalFileSafely, readLocalFileSafely } from "../infra/fs-safe.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { assertLocalMediaAllowed, resolveLocalMediaRoots } from "../media/local-media-access.js"; import { resolveLocalMediaPath } from "../media/local-media-path.js"; import { probePlaybackMediaFileDescriptor } from "../media/media-probe.js"; @@ -831,16 +832,10 @@ function setCachedSessionManagedOutgoingAttachmentIndex( index, }, ); - while ( - sessionManagedOutgoingAttachmentIndexCache.size > - MAX_SESSION_MANAGED_OUTGOING_ATTACHMENT_INDEX_CACHE_ENTRIES - ) { - const oldestKey = sessionManagedOutgoingAttachmentIndexCache.keys().next().value; - if (!oldestKey) { - break; - } - sessionManagedOutgoingAttachmentIndexCache.delete(oldestKey); - } + pruneMapToMaxSize( + sessionManagedOutgoingAttachmentIndexCache, + MAX_SESSION_MANAGED_OUTGOING_ATTACHMENT_INDEX_CACHE_ENTRIES, + ); } function sameManagedOutgoingAttachmentTranscriptStat( diff --git a/src/gateway/mcp-http.runtime.test.ts b/src/gateway/mcp-http.runtime.test.ts index c09ea433016a..db80b8542f9f 100644 --- a/src/gateway/mcp-http.runtime.test.ts +++ b/src/gateway/mcp-http.runtime.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { setPluginToolMeta } from "../plugins/tools.js"; import { @@ -44,6 +44,10 @@ beforeEach(() => { ); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("resolveMcpLoopbackScopedTools", () => { it("keeps the full session scope without a grant allowlist", () => { const scoped = resolveMcpLoopbackScopedTools(scopeParams()); @@ -159,6 +163,26 @@ describe("resolveMcpLoopbackScopedTools", () => { }); describe("McpLoopbackToolCache", () => { + it("expires at the ttl boundary and partitions rows by config identity", () => { + vi.useFakeTimers(); + const cache = new McpLoopbackToolCache(); + const cfgA = {} as OpenClawConfig; + const cfgB = {} as OpenClawConfig; + const paramsA = scopeParams({ cfg: cfgA }); + + cache.resolve(paramsA); + cache.resolve(paramsA); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(30_000); + cache.resolve(paramsA); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(2); + + cache.resolve(scopeParams({ cfg: cfgB })); + cache.resolve(paramsA); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(3); + }); + it("does not share cache rows across different grant allowlists", () => { const cache = new McpLoopbackToolCache(); const cfg = {} as OpenClawConfig; diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 9034c6f078fe..f3d014c81fd1 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -3,6 +3,7 @@ import { applyEmbeddedAttemptToolsAllow } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; import { normalizeToolName } from "../agents/tool-policy.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { DirectoryCache } from "../infra/outbound/directory-cache.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import type { McpLoopbackRequestContext } from "./mcp-grant-store.js"; import { @@ -28,8 +29,6 @@ type CachedScopedTools = { workspaceDir: string | undefined; tools: McpLoopbackTool[]; toolSchema: McpToolSchemaEntry[]; - configRef: OpenClawConfig; - time: number; }; type McpLoopbackScopeParams = Omit & { @@ -161,7 +160,7 @@ function applyPolicyToolsAllow( /** Short-lived cache for loopback tool lists keyed by session/channel context. */ export class McpLoopbackToolCache { - #entries = new Map(); + #entries = new DirectoryCache(TOOL_CACHE_TTL_MS, TOOL_CACHE_MAX_ENTRIES); resolve(params: McpLoopbackScopeParams): CachedScopedTools { // Callers differing only in capabilities must not share cached tool lists. @@ -229,16 +228,8 @@ export class McpLoopbackToolCache { ? "non-owner" : "unknown-owner", ].join("\u0000"); - const now = Date.now(); - for (const [key, entry] of this.#entries) { - if (now - entry.time >= TOOL_CACHE_TTL_MS) { - this.#entries.delete(key); - } - } - const cached = this.#entries.get(cacheKey); - // Config object identity is part of the cache contract so explicit gateway - // reloads invalidate tool scope and schema without filesystem polling. - if (cached && cached.configRef === params.cfg && now - cached.time < TOOL_CACHE_TTL_MS) { + const cached = this.#entries.get(cacheKey, params.cfg); + if (cached) { return cached; } @@ -248,17 +239,8 @@ export class McpLoopbackToolCache { workspaceDir: next.workspaceDir, tools: next.tools, toolSchema: buildMcpToolSchema(next.tools), - configRef: params.cfg, - time: now, }; - this.#entries.set(cacheKey, nextEntry); - while (this.#entries.size > TOOL_CACHE_MAX_ENTRIES) { - const oldestKey = this.#entries.keys().next().value; - if (oldestKey === undefined) { - break; - } - this.#entries.delete(oldestKey); - } + this.#entries.set(cacheKey, nextEntry, params.cfg); return nextEntry; } } diff --git a/src/gateway/message-action-turn-capability.ts b/src/gateway/message-action-turn-capability.ts index 350fd43114f2..1161245536e9 100644 --- a/src/gateway/message-action-turn-capability.ts +++ b/src/gateway/message-action-turn-capability.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { InternalChannelThreadingToolContext } from "../channels/threading-tool-context-internal.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { isDeliverableMessageChannel, @@ -88,13 +89,6 @@ function copyToolContext( }; } -function evictOldestCapability(): void { - const oldest = capabilitiesByToken.keys().next().value; - if (typeof oldest === "string") { - capabilitiesByToken.delete(oldest); - } -} - function sweepExpiredMessageActionTurnCapabilities(nowMs: number = Date.now()): number { let removed = 0; for (const [token, capability] of capabilitiesByToken) { @@ -131,11 +125,9 @@ export function mintMessageActionTurnCapability(params: { } const nowMs = params.nowMs ?? Date.now(); sweepExpiredMessageActionTurnCapabilities(nowMs); - while (capabilitiesByToken.size >= MAX_ACTIVE_CAPABILITIES) { - // A bounded fail-closed store prevents abandoned long-running turns from - // growing process memory without creating a second persistent state path. - evictOldestCapability(); - } + // A bounded fail-closed store prevents abandoned long-running turns from + // growing process memory without creating a second persistent state path. + pruneMapToMaxSize(capabilitiesByToken, MAX_ACTIVE_CAPABILITIES - 1); const token = randomBytes(32).toString("base64url"); capabilitiesByToken.set(token, { agentId, diff --git a/src/gateway/node-pairing-ssh-verify.ts b/src/gateway/node-pairing-ssh-verify.ts index 1f8e496704b9..b31e40fa1743 100644 --- a/src/gateway/node-pairing-ssh-verify.ts +++ b/src/gateway/node-pairing-ssh-verify.ts @@ -8,6 +8,7 @@ import net from "node:net"; import os from "node:os"; import type { GatewayNodePairingConfig } from "../config/types.gateway.js"; import { normalizeDevicePublicKeyBase64Url } from "../infra/device-identity.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { isLoopbackAddress, isPrivateOrLoopbackAddress, isTrustedProxyAddress } from "./net.js"; import { @@ -171,13 +172,7 @@ function pruneCooldowns(nowMs: number) { cooldownExpiryByKey.delete(key); } } - while (cooldownExpiryByKey.size > MAX_COOLDOWN_ENTRIES) { - const oldest = cooldownExpiryByKey.keys().next().value; - if (oldest === undefined) { - break; - } - cooldownExpiryByKey.delete(oldest); - } + pruneMapToMaxSize(cooldownExpiryByKey, MAX_COOLDOWN_ENTRIES); } /** diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index caf32ddd1085..b2ecac13e4e3 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -18,6 +18,7 @@ import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { logWarn } from "../logger.js"; import { renderFileContextBlock } from "../media/file-context.js"; import { @@ -176,16 +177,6 @@ function pruneExpiredResponseSessions(now: number) { } } -function evictOverflowResponseSessions() { - while (responseSessionMap.size > MAX_RESPONSE_SESSION_ENTRIES) { - const oldestKey = responseSessionMap.keys().next().value; - if (!oldestKey) { - return; - } - responseSessionMap.delete(oldestKey); - } -} - function storeResponseSession( responseId: string, sessionKey: string, @@ -196,7 +187,7 @@ function storeResponseSession( responseSessionMap.delete(responseId); responseSessionMap.set(responseId, { ...scope, sessionKey, ts: now }); pruneExpiredResponseSessions(now); - evictOverflowResponseSessions(); + pruneMapToMaxSize(responseSessionMap, MAX_RESPONSE_SESSION_ENTRIES); } function lookupResponseSession( diff --git a/src/gateway/plugin-icon-http.ts b/src/gateway/plugin-icon-http.ts index e08ebc8631d7..e94afa644e06 100644 --- a/src/gateway/plugin-icon-http.ts +++ b/src/gateway/plugin-icon-http.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { fileTypeFromBuffer } from "file-type"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { readRemoteMediaBuffer } from "../media/fetch.js"; import { createImageProcessor, @@ -130,13 +131,7 @@ function rememberIcon( ): PluginIconCacheEntry { cache.delete(cacheKey); cache.set(cacheKey, entry); - while (cache.size > PLUGIN_ICON_CACHE_MAX_ENTRIES) { - const oldest = cache.keys().next(); - if (oldest.done) { - break; - } - cache.delete(oldest.value); - } + pruneMapToMaxSize(cache, PLUGIN_ICON_CACHE_MAX_ENTRIES); return entry; } diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 1b09b6d22847..e4114f76fe10 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -479,7 +479,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage } const { preserveRestartAttempts = false, preserveManualStop = false } = optsValue; const cfg = getRuntimeConfig(); - resetDirectoryCache({ channel: channelId, accountId }); + resetDirectoryCache({ cfg, channel: channelId, accountId }); const store = getStore(channelId); const accountIds = accountId ? [accountId] @@ -795,7 +795,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage ? setRuntimeFromTaskStatus(channelId, id, next, abort.signal) : getRuntime(channelId, id), invalidateDirectoryCache: () => - resetDirectoryCache({ channel: channelId, accountId: id }), + resetDirectoryCache({ cfg, channel: channelId, accountId: id }), ...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}), }), ).finally(recordDuration); diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts index f9695795132a..865fe1ec5139 100644 --- a/src/gateway/server-maintenance.ts +++ b/src/gateway/server-maintenance.ts @@ -9,6 +9,7 @@ import { } from "../agents/worktrees/service.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { sweepStaleRunContexts } from "../infra/agent-run-registry.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { pruneOrphanedDeliveryQueueMedia } from "../infra/outbound/delivery-queue-media-spool.js"; import { cleanOldMedia } from "../media/store.js"; import { createLazyPromiseLoader } from "../shared/lazy-promise.js"; @@ -239,17 +240,7 @@ export function startGatewayMaintenanceTimers(params: { } } - if (params.agentRunSeq.size > AGENT_RUN_SEQ_MAX) { - const excess = params.agentRunSeq.size - AGENT_RUN_SEQ_MAX; - let removed = 0; - for (const runId of params.agentRunSeq.keys()) { - params.agentRunSeq.delete(runId); - removed += 1; - if (removed >= excess) { - break; - } - } - } + pruneMapToMaxSize(params.agentRunSeq, AGENT_RUN_SEQ_MAX); for (const [runId, entry] of params.chatAbortControllers) { if (entry.projectSessionTerminalPending === true) { diff --git a/src/gateway/server-methods/chat-startup-projection-memo.ts b/src/gateway/server-methods/chat-startup-projection-memo.ts index d73ca189c8b8..ff7c5bf9c6b2 100644 --- a/src/gateway/server-methods/chat-startup-projection-memo.ts +++ b/src/gateway/server-methods/chat-startup-projection-memo.ts @@ -6,6 +6,7 @@ import { hashRuntimeConfigValue } from "../../config/runtime-snapshot.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import type { GatewayModelCatalogSnapshot } from "../server-model-catalog.types.js"; import { listAgentsForGateway } from "../session-utils.js"; @@ -81,13 +82,7 @@ function setChatStartupMetadataMemo( ): void { memo.metadataByKey.delete(key); memo.metadataByKey.set(key, value); - if (memo.metadataByKey.size <= CHAT_STARTUP_METADATA_CACHE_MAX_ENTRIES) { - return; - } - const oldestKey = memo.metadataByKey.keys().next().value; - if (oldestKey) { - memo.metadataByKey.delete(oldestKey); - } + pruneMapToMaxSize(memo.metadataByKey, CHAT_STARTUP_METADATA_CACHE_MAX_ENTRIES); } function resolveChatStartupMetadataMemoKey(params: { diff --git a/src/gateway/server-methods/nodes.invoke.ts b/src/gateway/server-methods/nodes.invoke.ts index 8048c453876c..8277db2a7814 100644 --- a/src/gateway/server-methods/nodes.invoke.ts +++ b/src/gateway/server-methods/nodes.invoke.ts @@ -6,6 +6,7 @@ import { missingScopeErrorShape, validateNodeInvokeParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { isAdminOnlyNodeInvokeCommand, isBrowserProxyNodeInvokeCommand, @@ -77,13 +78,7 @@ function emitTalkPttNodeEvent(params: { const sessionId = `node:${params.nodeId}:talk:${captureId}`; const seq = (talkPttEventSeqBySessionId.get(sessionId) ?? 0) + 1; talkPttEventSeqBySessionId.set(sessionId, seq); - while (talkPttEventSeqBySessionId.size > 2048) { - const oldest = talkPttEventSeqBySessionId.keys().next().value; - if (oldest === undefined) { - break; - } - talkPttEventSeqBySessionId.delete(oldest); - } + pruneMapToMaxSize(talkPttEventSeqBySessionId, 2048); const type = params.command === "talk.ptt.start" diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 02b524166b00..0cc866177299 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -14,6 +14,7 @@ import { validateSessionsCatalogReadParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { getPluginRegistryRuntime } from "../../plugins/registry-runtime-binding.js"; import type { PluginRegistry } from "../../plugins/registry-types.js"; import { getActivePluginRegistry } from "../../plugins/runtime.js"; @@ -462,13 +463,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { // out-of-phase clients but expires before the UI's 5s fast follow, so changed rows surface there. // Expired and rejected work is removed; retaining it would mask provider recovery or new sessions. cache.set(listKey, entry); - while (cache.size > SESSION_CATALOG_LIST_CACHE_MAX_ENTRIES) { - const oldest = cache.keys().next(); - if (oldest.done) { - break; - } - cache.delete(oldest.value); - } + pruneMapToMaxSize(cache, SESSION_CATALOG_LIST_CACHE_MAX_ENTRIES); try { const result = await operation; if (cache.get(listKey) === entry) { diff --git a/src/gateway/server-methods/session-typing-state.ts b/src/gateway/server-methods/session-typing-state.ts index 14958d8b6993..a5f8b5e1c630 100644 --- a/src/gateway/server-methods/session-typing-state.ts +++ b/src/gateway/server-methods/session-typing-state.ts @@ -1,3 +1,4 @@ +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; @@ -154,8 +155,6 @@ export function updateTypingConnections(params: { } typingConnections.delete(params.key); typingConnections.set(params.key, connections); - if (typingConnections.size > MAX_TYPING_THROTTLE_KEYS) { - typingConnections.delete(typingConnections.keys().next().value ?? ""); - } + pruneMapToMaxSize(typingConnections, MAX_TYPING_THROTTLE_KEYS); return true; } diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts index efbf2cf516f8..46a33797f5b8 100644 --- a/src/gateway/server-methods/sessions-files.ts +++ b/src/gateway/server-methods/sessions-files.ts @@ -22,6 +22,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { resolveToCwd as resolveSessionToolPathToCwd } from "../../agents/sessions/tools/path-utils.js"; import { runGit } from "../../agents/worktrees/git.js"; import { FsSafeError } from "../../infra/fs-safe.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { readSessionTranscriptVisibleMessageDelta, @@ -131,13 +132,7 @@ function readTouchedFilesCache(key: string): TouchedFilesCacheEntry | undefined function writeTouchedFilesCache(key: string, entry: TouchedFilesCacheEntry): void { touchedFilesCache.delete(key); touchedFilesCache.set(key, entry); - while (touchedFilesCache.size > TOUCHED_FILES_CACHE_LIMIT) { - const oldestKey = touchedFilesCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - touchedFilesCache.delete(oldestKey); - } + pruneMapToMaxSize(touchedFilesCache, TOUCHED_FILES_CACHE_LIMIT); } function sessionFilesError(type: string, message: string, details?: Record) { diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 78cc45201764..25ce59b5453f 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -16,6 +16,7 @@ import { buildRuntimeCompatibleMcpToolInventory } from "../../agents/tools-effec import type { SessionToolOverrides } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { toErrorObject } from "../../infra/errors.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { logDebug, logWarn } from "../../logger.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { sessionDeliveryOrigin } from "../../utils/delivery-context.shared.js"; @@ -130,16 +131,6 @@ function buildToolsEffectiveCacheKey(params: { }); } -function trimToolsEffectiveCache(): void { - while (toolsEffectiveCache.size > TOOLS_EFFECTIVE_CACHE_LIMIT) { - const oldest = toolsEffectiveCache.keys().next().value; - if (typeof oldest !== "string") { - return; - } - toolsEffectiveCache.delete(oldest); - } -} - function buildMcpConfigSummaryCacheKey(params: { context: TrustedToolsEffectiveContext; workspaceDir: string; @@ -153,16 +144,6 @@ function buildMcpConfigSummaryCacheKey(params: { }); } -function trimMcpConfigSummaryCache(): void { - while (mcpConfigSummaryCache.size > MCP_CONFIG_SUMMARY_CACHE_LIMIT) { - const oldest = mcpConfigSummaryCache.keys().next().value; - if (typeof oldest !== "string") { - return; - } - mcpConfigSummaryCache.delete(oldest); - } -} - function resolveCachedSessionMcpConfigSummary(params: { context: TrustedToolsEffectiveContext; workspaceDir: string; @@ -178,14 +159,14 @@ function resolveCachedSessionMcpConfigSummary(params: { ...(params.context.toolOverrides ? { toolOverrides: params.context.toolOverrides } : {}), }); mcpConfigSummaryCache.set(key, summary); - trimMcpConfigSummaryCache(); + pruneMapToMaxSize(mcpConfigSummaryCache, MCP_CONFIG_SUMMARY_CACHE_LIMIT); return summary; } function cacheToolsEffectiveResult(key: string, value: BaseToolsEffectiveResolution): void { toolsEffectiveCache.delete(key); toolsEffectiveCache.set(key, { value, createdAtMs: nowForToolsEffectiveCache() }); - trimToolsEffectiveCache(); + pruneMapToMaxSize(toolsEffectiveCache, TOOLS_EFFECTIVE_CACHE_LIMIT); } // Base inventory resolution is pure CPU work, but it can still fan through diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index d2691d232790..fff486cb831b 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -15,6 +15,7 @@ import { resolveEventSessionRoutingPolicy, scopedHeartbeatWakeOptionsForPolicy, } from "../infra/event-session-routing.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; import { runWithGatewayIndependentRootWorkContinuation } from "../process/gateway-work-admission.js"; import { resolveAgentHarnessSessionContextError } from "../sessions/agent-harness-session-key.js"; @@ -172,13 +173,7 @@ function shouldDropDuplicateVoiceTranscript(params: { break; } } - while (recentVoiceTranscripts.size > MAX_RECENT_VOICE_TRANSCRIPTS) { - const oldestKey = recentVoiceTranscripts.keys().next().value; - if (oldestKey === undefined) { - break; - } - recentVoiceTranscripts.delete(oldestKey); - } + pruneMapToMaxSize(recentVoiceTranscripts, MAX_RECENT_VOICE_TRANSCRIPTS); } return false; @@ -328,13 +323,7 @@ function shouldDropDuplicateExecFinished(params: { break; } } - while (recentExecFinishedRuns.size > MAX_RECENT_EXEC_FINISHED_RUNS) { - const oldestKey = recentExecFinishedRuns.keys().next().value; - if (oldestKey === undefined) { - break; - } - recentExecFinishedRuns.delete(oldestKey); - } + pruneMapToMaxSize(recentExecFinishedRuns, MAX_RECENT_EXEC_FINISHED_RUNS); } return false; @@ -356,13 +345,7 @@ function pruneBoundedTimestampMap( return; } } - while (map.size > params.maxEntries) { - const oldestKey = map.keys().next().value; - if (oldestKey === undefined) { - return; - } - map.delete(oldestKey); - } + pruneMapToMaxSize(map, params.maxEntries); } function compactExecEventOutput(raw: string) { diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index f481a6815a67..0696a43abbf0 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -9,6 +9,7 @@ import { getRuntimeConfig } from "../config/io.js"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.js"; import { getAgentRunContext } from "../infra/agent-run-registry.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { normalizeAgentId, parseAgentSessionKey, @@ -48,10 +49,7 @@ function setResolvedSessionKeyCache( !resolvedSessionKeyByRunId.has(cacheKey) && resolvedSessionKeyByRunId.size >= RUN_LOOKUP_CACHE_LIMIT ) { - const oldest = resolvedSessionKeyByRunId.keys().next().value; - if (oldest) { - resolvedSessionKeyByRunId.delete(oldest); - } + pruneMapToMaxSize(resolvedSessionKeyByRunId, RUN_LOOKUP_CACHE_LIMIT - 1); } let expiresAt: number | null = null; if (sessionKey === null) { diff --git a/src/gateway/server/hooks-request-handler.ts b/src/gateway/server/hooks-request-handler.ts index e1377f692a65..7bfb35fb6b8f 100644 --- a/src/gateway/server/hooks-request-handler.ts +++ b/src/gateway/server/hooks-request-handler.ts @@ -1,6 +1,7 @@ // Hook request handler validates hook tokens, applies mappings, dedupes requests, and dispatches wake or agent work. import { createHash } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveHookExternalContentSource as resolveHookExternalContentSourceFromSession } from "../../security/external-content.js"; import { safeEqualSecret } from "../../security/secret-equal.js"; @@ -121,13 +122,7 @@ export function createHooksRequestHandler( hookReplayCache.delete(key); } } - while (hookReplayCache.size > DEDUPE_MAX) { - const oldestKey = hookReplayCache.keys().next().value; - if (!oldestKey) { - break; - } - hookReplayCache.delete(oldestKey); - } + pruneMapToMaxSize(hookReplayCache, DEDUPE_MAX); }; const buildHookReplayCacheKey = (params: HookReplayScope): string | undefined => { diff --git a/src/gateway/server/ws-connection/handshake-auth-log-limiter.ts b/src/gateway/server/ws-connection/handshake-auth-log-limiter.ts index f54deeb885bc..eb510925b144 100644 --- a/src/gateway/server/ws-connection/handshake-auth-log-limiter.ts +++ b/src/gateway/server/ws-connection/handshake-auth-log-limiter.ts @@ -1,5 +1,6 @@ // Rate limiter for noisy websocket handshake auth logs. import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; +import { pruneMapToMaxSize } from "../../../infra/map-size.js"; /** Decision returned for a handshake auth log attempt. */ type HandshakeAuthLogDecision = { @@ -27,7 +28,7 @@ export class HandshakeAuthLogLimiter { register(key: string, nowMs = Date.now()): HandshakeAuthLogDecision { const entry = this.entries.get(key); if (!entry) { - this.pruneIfNeeded(); + pruneMapToMaxSize(this.entries, this.maxEntries - 1); this.entries.set(key, { lastLoggedAtMs: nowMs, suppressedSinceLastLog: 0, @@ -45,16 +46,6 @@ export class HandshakeAuthLogLimiter { entry.suppressedSinceLastLog = 0; return { shouldLog: true, suppressedSinceLastLog }; } - - private pruneIfNeeded(): void { - if (this.entries.size < this.maxEntries) { - return; - } - const oldestKey = this.entries.keys().next().value; - if (oldestKey !== undefined) { - this.entries.delete(oldestKey); - } - } } /** Build the limiter key from auth failure context. */ diff --git a/src/gateway/session-observer-model.ts b/src/gateway/session-observer-model.ts index 3855ab5ff055..20eb87d23dea 100644 --- a/src/gateway/session-observer-model.ts +++ b/src/gateway/session-observer-model.ts @@ -22,6 +22,7 @@ import { import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { redactToolPayloadText } from "../logging/redact.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { @@ -102,13 +103,7 @@ export function rememberSessionObserverRevisionFloor( floors.delete(sessionKey); floors.set(sessionKey, candidate); } - while (floors.size > MAX_REVISION_FLOORS) { - const oldest = floors.keys().next().value; - if (oldest === undefined) { - break; - } - floors.delete(oldest); - } + pruneMapToMaxSize(floors, MAX_REVISION_FLOORS); } export function rememberSessionObserverDormantRun( @@ -159,13 +154,7 @@ export function markSessionObserverRunSuperseded( ): void { runs.delete(runId); runs.set(runId, observedAt); - while (runs.size > MAX_SUPERSEDED_RUNS) { - const oldest = runs.keys().next().value; - if (oldest === undefined) { - break; - } - runs.delete(oldest); - } + pruneMapToMaxSize(runs, MAX_SUPERSEDED_RUNS); } export function createDormantSessionObserverRun( diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts index 23c708164c99..6c5b303639de 100644 --- a/src/gateway/session-transcript-files.fs.ts +++ b/src/gateway/session-transcript-files.fs.ts @@ -19,6 +19,7 @@ import { import { hasErrnoCode } from "../infra/errors.js"; import { readFileWindowFully } from "../infra/file-read.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; type ArchiveFileReason = SessionArchiveReason; @@ -212,9 +213,7 @@ async function listResetArchiveCandidatesForTranscriptAsync( dirSize: dirStat.size, archives: boundedArchives, }); - if (resetArchiveDiscoveryCache.size > MAX_RESET_ARCHIVE_DISCOVERY_CACHE_ENTRIES) { - resetArchiveDiscoveryCache.delete(resetArchiveDiscoveryCache.keys().next().value ?? ""); - } + pruneMapToMaxSize(resetArchiveDiscoveryCache, MAX_RESET_ARCHIVE_DISCOVERY_CACHE_ENTRIES); return boundedArchives; } diff --git a/src/gateway/session-transcript-title-reader.ts b/src/gateway/session-transcript-title-reader.ts index f34143bc1c39..ca628c68a625 100644 --- a/src/gateway/session-transcript-title-reader.ts +++ b/src/gateway/session-transcript-title-reader.ts @@ -7,6 +7,7 @@ import { type SessionTranscriptMessageEvent, type SessionTranscriptReadScope, } from "../config/sessions/session-accessor.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { extractMessageRole, @@ -44,13 +45,7 @@ function sqliteTitleFieldCacheKey(target: ResolvedTranscriptReadTarget): string function setSqliteTitleFieldCache(key: string, entry: SqliteTitleFieldCacheEntry): void { sqliteTitleFieldCache.delete(key); sqliteTitleFieldCache.set(key, entry); - if (sqliteTitleFieldCache.size <= SQLITE_TITLE_FIELD_CACHE_MAX_ENTRIES) { - return; - } - const oldestKey = sqliteTitleFieldCache.keys().next().value; - if (oldestKey !== undefined) { - sqliteTitleFieldCache.delete(oldestKey); - } + pruneMapToMaxSize(sqliteTitleFieldCache, SQLITE_TITLE_FIELD_CACHE_MAX_ENTRIES); } function readSqliteTitleProbeRange( diff --git a/src/gateway/session-utils-core.ts b/src/gateway/session-utils-core.ts index c5f0b31ff38f..ec8454fdafa6 100644 --- a/src/gateway/session-utils-core.ts +++ b/src/gateway/session-utils-core.ts @@ -11,6 +11,7 @@ import { import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import { isTerminalSessionStatus, type SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { resolveNonNegativeNumber } from "../shared/number-coercion.js"; import { truncateUtf16Safe } from "../utils.js"; import { @@ -289,13 +290,7 @@ function rememberSingleRowChildSessionCandidateCacheEntry( singleRowChildSessionCandidateCache.delete(storePath); } singleRowChildSessionCandidateCache.set(storePath, entry); - if (singleRowChildSessionCandidateCache.size <= SINGLE_ROW_CONTEXT_CACHE_MAX_ENTRIES) { - return; - } - const oldestKey = singleRowChildSessionCandidateCache.keys().next().value; - if (oldestKey) { - singleRowChildSessionCandidateCache.delete(oldestKey); - } + pruneMapToMaxSize(singleRowChildSessionCandidateCache, SINGLE_ROW_CONTEXT_CACHE_MAX_ENTRIES); } function buildStoreChildSessionCandidateIndex( diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index abf50cc3d778..ab2fa48266f6 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -20,6 +20,7 @@ import { streamSessionTranscriptLines } from "../config/sessions/transcript-stre import { selectSessionTranscriptActiveEntries } from "../config/sessions/transcript-tree.js"; import { readFileWindowFully } from "../infra/file-read.js"; import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { extractAssistantVisibleText } from "../shared/chat-message-content.js"; import { truncateUtf16Safe } from "../utils.js"; import { estimateStringChars, estimateTokensFromChars } from "../utils/cjk-chars.js"; @@ -566,9 +567,7 @@ async function readSessionTranscriptIndex( if (opts.cache !== "skip") { transcriptIndexes.delete(filePath); transcriptIndexes.set(filePath, cached); - if (transcriptIndexes.size > MAX_TRANSCRIPT_INDEXES) { - transcriptIndexes.delete(transcriptIndexes.keys().next().value ?? ""); - } + pruneMapToMaxSize(transcriptIndexes, MAX_TRANSCRIPT_INDEXES); } } let index: SessionTranscriptIndex; diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts index 2b67be638e90..55679416a6e0 100644 --- a/src/gateway/watch-node-http.ts +++ b/src/gateway/watch-node-http.ts @@ -31,6 +31,7 @@ import { requestDevicePairing, verifyDeviceToken, } from "../infra/device-pairing.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { captureAuthenticatedNodePairingState } from "../infra/node-pairing-state.js"; import { approveNodePairing, @@ -254,13 +255,7 @@ function createChallengeStore() { challenges.delete(oldest[0]); } } - while (challenges.size >= MAX_PENDING_CHALLENGES) { - const oldest = challenges.keys().next().value; - if (typeof oldest !== "string") { - break; - } - challenges.delete(oldest); - } + pruneMapToMaxSize(challenges, MAX_PENDING_CHALLENGES - 1); const nonce = randomBytes(24).toString("base64url"); const expiresAtMs = current + CHALLENGE_TTL_MS; challenges.set(nonce, { clientKey, expiresAtMs }); diff --git a/src/gateway/worker-environments/live-events.ts b/src/gateway/worker-environments/live-events.ts index ea3e61ca8186..7ba4af83bfbc 100644 --- a/src/gateway/worker-environments/live-events.ts +++ b/src/gateway/worker-environments/live-events.ts @@ -18,6 +18,7 @@ import { registerAgentRunContext, releaseAgentRunContext, } from "../../infra/agent-run-registry.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import type { WorkerConnectionIdentity } from "./connection-identity.js"; import { createWorkerLiveTrajectoryRecorder, @@ -722,12 +723,7 @@ export function createWorkerLiveEventReceiver(options: WorkerLiveEventReceiverOp // Refresh recency first so a re-fenced environment keeps its newest stale-owner epoch. fencedEnvironmentEpochs.delete(environmentId); fencedEnvironmentEpochs.set(environmentId, fencedEpoch); - if (fencedEnvironmentEpochs.size > MAX_FENCED_ENVIRONMENTS) { - const oldestEnvironmentId = fencedEnvironmentEpochs.keys().next().value; - if (oldestEnvironmentId) { - fencedEnvironmentEpochs.delete(oldestEnvironmentId); - } - } + pruneMapToMaxSize(fencedEnvironmentEpochs, MAX_FENCED_ENVIRONMENTS); } }; diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 75b5b3e0c88e..79d7dffd494f 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -32,7 +32,10 @@ import { import { removePreparedBackupArchive, writeArchiveStreamToFile } from "./backup-create-stream.js"; import { writeTarArchiveWithRetry } from "./backup-tar-retry.js"; import { isVolatileBackupPath } from "./backup-volatile-filter.js"; -import { createBackupVolatileStatCache } from "./backup-volatile-stat-cache.js"; +import { + createBackupLinkCache, + createBackupVolatileStatCache, +} from "./backup-volatile-stat-cache.js"; import { formatErrorMessage } from "./errors.js"; import { sameFileIdentity } from "./fs-safe-advanced.js"; import { writeJson } from "./json-files.js"; @@ -48,18 +51,6 @@ import { withLegacyAuditMigrationLease } from "./state-migrations.audit-coordina const loadTarRuntime = createLazyRuntimeModule(() => import("tar")); -type BackupLinkCacheKey = `${number}:${number}`; - -class BackupLinkCache extends Map { - override get(_key: BackupLinkCacheKey): undefined { - return undefined; - } - - override set(_key: BackupLinkCacheKey, _value: string): this { - return this; - } -} - export type BackupCreateOptions = { output?: string; dryRun?: boolean; @@ -945,7 +936,7 @@ export async function createBackupArchive( gzip: true, portable: true, preservePaths: true, - linkCache: new BackupLinkCache(), + linkCache: createBackupLinkCache(), statCache: createBackupVolatileStatCache(volatilePlan), filter: tarFilter, onWriteEntry: (entry) => { diff --git a/src/infra/backup-volatile-stat-cache.ts b/src/infra/backup-volatile-stat-cache.ts index 6fe4be9c4950..81673678c35b 100644 --- a/src/infra/backup-volatile-stat-cache.ts +++ b/src/infra/backup-volatile-stat-cache.ts @@ -2,6 +2,7 @@ import type { Stats } from "node:fs"; import { isVolatileBackupPath } from "./backup-volatile-filter.js"; type VolatileFilterPlan = Parameters[1]; +type BackupLinkCacheKey = `${number}:${number}`; const VOLATILE_BACKUP_SYNTHETIC_STAT = { isBlockDevice: () => false, @@ -31,8 +32,24 @@ class BackupVolatileStatCache extends Map { } } +// node-tar emits hardlink entries when this cache returns an earlier inode path. +// Suppressing both reads and writes keeps every backup entry independently restorable. +class BackupLinkCache extends Map { + override get(_key: BackupLinkCacheKey): undefined { + return undefined; + } + + override set(_key: BackupLinkCacheKey, _value: string): this { + return this; + } +} + export function createBackupVolatileStatCache( volatilePlan: VolatileFilterPlan, ): Map { return new BackupVolatileStatCache(volatilePlan); } + +export function createBackupLinkCache(): Map { + return new BackupLinkCache(); +} diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 839f06ba379d..406a1a3d83d2 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -21,6 +21,7 @@ import { signEd25519Payload, verifyEd25519Signature, } from "./ed25519-signature.js"; +import { pruneMapToMaxSize } from "./map-size.js"; export type { DeviceIdentity } from "./device-identity-store.js"; @@ -157,12 +158,7 @@ export function loadOrCreateProcessDeviceIdentity( return cached; } const identity = loadOrCreateDeviceIdentityOwned(resolvedOptions); - if (processDeviceIdentities.size >= MAX_PROCESS_DEVICE_IDENTITIES) { - const oldestKey = processDeviceIdentities.keys().next().value; - if (oldestKey !== undefined) { - processDeviceIdentities.delete(oldestKey); - } - } + pruneMapToMaxSize(processDeviceIdentities, MAX_PROCESS_DEVICE_IDENTITIES - 1); processDeviceIdentities.set(cacheKey, identity); return identity; }); diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts index d69409a2527e..f861c1d7d058 100644 --- a/src/infra/executable-path.ts +++ b/src/infra/executable-path.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { expandHomePrefix } from "./home-dir.js"; +import { pruneMapToMaxSize } from "./map-size.js"; function isDriveLessWindowsRootedPath(value: string): boolean { return process.platform === "win32" && /^:[\\/]/.test(value); @@ -127,13 +128,7 @@ function cacheExecutablePath(key: string, resolved: string | undefined): void { expiresAt: Date.now() + EXECUTABLE_PATH_CACHE_TTL_MS, resolved: resolved ?? null, }); - while (executablePathCache.size > EXECUTABLE_PATH_CACHE_MAX_ENTRIES) { - const oldest = executablePathCache.keys().next(); - if (oldest.done) { - break; - } - executablePathCache.delete(oldest.value); - } + pruneMapToMaxSize(executablePathCache, EXECUTABLE_PATH_CACHE_MAX_ENTRIES); } function executablePathCacheKey( diff --git a/src/infra/kysely-sync.ts b/src/infra/kysely-sync.ts index ad73b0129662..7321b14fab12 100644 --- a/src/infra/kysely-sync.ts +++ b/src/infra/kysely-sync.ts @@ -2,6 +2,7 @@ import type { DatabaseSync, SQLInputValue, StatementSync } from "node:sqlite"; import type { Compilable, CompiledQuery, Kysely, QueryResult } from "kysely"; import { InsertQueryNode, Kysely as KyselyInstance, SqliteDialect } from "kysely"; +import { pruneMapToMaxSize } from "./map-size.js"; // Sync query helpers execute compiled Kysely SQL against node:sqlite without // going through Kysely's async driver path. @@ -201,12 +202,7 @@ function executeWithCachedStatement( statement = db.prepare(sql); if (!cached && cache.candidates.delete(sql)) { cache.statements.set(sql, statement); - if (cache.statements.size > statementCacheCapacity) { - const oldestSql = cache.statements.keys().next().value; - if (oldestSql !== undefined) { - cache.statements.delete(oldestSql); - } - } + pruneMapToMaxSize(cache.statements, statementCacheCapacity); } else if (!cached) { // Admit only on second use so variable placeholder counts cannot fill // the native statement cache with one-shot SQL strings. diff --git a/src/infra/outbound/channel-bootstrap.runtime.ts b/src/infra/outbound/channel-bootstrap.runtime.ts index cc3a87426833..e25f01fa001d 100644 --- a/src/infra/outbound/channel-bootstrap.runtime.ts +++ b/src/infra/outbound/channel-bootstrap.runtime.ts @@ -11,6 +11,7 @@ import type { PluginChannelRegistration } from "../../plugins/registry-types.js" import type { PluginRegistry } from "../../plugins/registry.js"; import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js"; import type { DeliverableMessageChannel } from "../../utils/message-channel.js"; +import { pruneMapToMaxSize } from "../map-size.js"; const MAX_BOOTSTRAP_CONFIG_GENERATIONS = 64; let bootstrapRegistryGeneration: string | undefined; @@ -40,12 +41,7 @@ function resolveBootstrapRegistries( } // Agent-scoped configs may interleave within one registry generation. Keep a // bounded LRU so one caller cannot evict another on every delivery attempt. - if (bootstrapRegistriesByConfig.size >= MAX_BOOTSTRAP_CONFIG_GENERATIONS) { - const oldestConfigKey = bootstrapRegistriesByConfig.keys().next().value; - if (oldestConfigKey !== undefined) { - bootstrapRegistriesByConfig.delete(oldestConfigKey); - } - } + pruneMapToMaxSize(bootstrapRegistriesByConfig, MAX_BOOTSTRAP_CONFIG_GENERATIONS - 1); const registries = new Map(); bootstrapRegistriesByConfig.set(configKey, registries); return registries; diff --git a/src/infra/outbound/directory-cache.test.ts b/src/infra/outbound/directory-cache.test.ts index cb73866e4c69..432f080ea51f 100644 --- a/src/infra/outbound/directory-cache.test.ts +++ b/src/infra/outbound/directory-cache.test.ts @@ -1,6 +1,6 @@ // Covers directory cache key dimensions, TTL expiration, config invalidation, // recency refresh, bounded eviction, and matching clears. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { DirectoryCache, buildDirectoryCacheKey } from "./directory-cache.js"; @@ -34,7 +34,11 @@ describe("buildDirectoryCacheKey", () => { }); describe("DirectoryCache", () => { - it("expires entries after ttl and resets when config ref changes", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("expires entries at ttl and partitions entries by config identity", () => { vi.useFakeTimers(); const cache = new DirectoryCache(1_000); const cfgA = {} as OpenClawConfig; @@ -43,18 +47,18 @@ describe("DirectoryCache", () => { cache.set("a", "first", cfgA); expect(cache.get("a", cfgA)).toBe("first"); - vi.advanceTimersByTime(1_001); + vi.advanceTimersByTime(1_000); expect(cache.get("a", cfgA)).toBeUndefined(); cache.set("b", "second", cfgA); expect(cache.get("b", cfgB)).toBeUndefined(); - - vi.useRealTimers(); + expect(cache.get("b", cfgA)).toBe("second"); }); it("evicts least-recent entries, refreshes insertion order, and clears matches", () => { const cache = new DirectoryCache(60_000, 2); const cfg = {} as OpenClawConfig; + const otherCfg = {} as OpenClawConfig; cache.set("a", "A", cfg); cache.set("b", "B", cfg); @@ -65,8 +69,10 @@ describe("DirectoryCache", () => { expect(cache.get("b", cfg)).toBeUndefined(); expect(cache.get("c", cfg)).toBe("C"); - cache.clearMatching((key) => key.startsWith("c")); + cache.set("c", "other-C", otherCfg); + cache.clearMatching((key) => key.startsWith("c"), cfg); expect(cache.get("c", cfg)).toBeUndefined(); + expect(cache.get("c", otherCfg)).toBe("other-C"); cache.clear(cfg); expect(cache.get("a", cfg)).toBeUndefined(); diff --git a/src/infra/outbound/directory-cache.ts b/src/infra/outbound/directory-cache.ts index 702f268d134d..0633d6f74ce7 100644 --- a/src/infra/outbound/directory-cache.ts +++ b/src/infra/outbound/directory-cache.ts @@ -1,8 +1,8 @@ -// Directory cache stores short-lived channel directory lookups and invalidates -// them on config-object changes or resolver signature updates. +// Directory cache stores short-lived projections partitioned by config identity. import { resolveNonNegativeIntegerOption } from "@openclaw/normalization-core/number-coercion"; import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../map-size.js"; type CacheEntry = { value: T; @@ -33,8 +33,7 @@ export function buildDirectoryCacheKey(key: DirectoryCacheKey): string { * Small TTL cache for channel directory lookups tied to a config object reference. */ export class DirectoryCache { - private readonly cache = new Map>(); - private lastConfigRef: OpenClawConfig | null = null; + private cachesByConfig = new WeakMap>>(); private readonly ttlMs: number; private readonly maxSize: number; @@ -44,12 +43,12 @@ export class DirectoryCache { } /** - * Returns a cached value after applying config, TTL, and capacity invalidation. + * Returns a cached value after applying config scoping, TTL, and capacity invalidation. */ get(key: string, cfg: OpenClawConfig): T | undefined { - this.resetIfConfigChanged(cfg); - this.pruneExpired(Date.now()); - const entry = this.cache.get(key); + const cache = this.cacheForConfig(cfg); + this.pruneExpired(cache, Date.now()); + const entry = cache.get(key); if (!entry) { return undefined; } @@ -60,64 +59,58 @@ export class DirectoryCache { * Stores a value and refreshes its recency for bounded-size eviction. */ set(key: string, value: T, cfg: OpenClawConfig): void { - this.resetIfConfigChanged(cfg); + const cache = this.cacheForConfig(cfg); const now = Date.now(); - this.pruneExpired(now); + this.pruneExpired(cache, now); // Refresh insertion order so active keys are less likely to be evicted. - if (this.cache.has(key)) { - this.cache.delete(key); - } - this.cache.set(key, { value, fetchedAt: now }); - this.evictToMaxSize(); + cache.delete(key); + cache.set(key, { value, fetchedAt: now }); + pruneMapToMaxSize(cache, this.maxSize); } /** * Clears matching entries without disturbing unrelated cached lookups. */ - clearMatching(match: (key: string) => boolean): void { - for (const key of this.cache.keys()) { + clearMatching(match: (key: string) => boolean, cfg: OpenClawConfig): void { + const cache = this.cachesByConfig.get(cfg); + if (!cache) { + return; + } + for (const key of cache.keys()) { if (match(key)) { - this.cache.delete(key); + cache.delete(key); } } } /** - * Drops all cached entries and optionally adopts the current config reference. + * Drops one config scope or all cached entries. */ clear(cfg?: OpenClawConfig): void { - this.cache.clear(); if (cfg) { - this.lastConfigRef = cfg; + this.cachesByConfig.delete(cfg); + } else { + this.cachesByConfig = new WeakMap(); } } - private resetIfConfigChanged(cfg: OpenClawConfig): void { - // Directory availability can change with config snapshots; ref changes must not leak stale entries. - if (this.lastConfigRef && this.lastConfigRef !== cfg) { - this.cache.clear(); + private cacheForConfig(cfg: OpenClawConfig): Map> { + let cache = this.cachesByConfig.get(cfg); + if (!cache) { + cache = new Map(); + this.cachesByConfig.set(cfg, cache); } - this.lastConfigRef = cfg; + return cache; } - private pruneExpired(now: number): void { + private pruneExpired(cache: Map>, now: number): void { if (this.ttlMs <= 0) { return; } - for (const [cacheKey, entry] of this.cache.entries()) { - if (now - entry.fetchedAt > this.ttlMs) { - this.cache.delete(cacheKey); + for (const [cacheKey, entry] of cache) { + if (now - entry.fetchedAt >= this.ttlMs) { + cache.delete(cacheKey); } } } - - private evictToMaxSize(): void { - while (this.cache.size > this.maxSize) { - const oldestKey = this.cache.keys().next().value; - if (typeof oldestKey !== "string") { - break; - } - this.cache.delete(oldestKey); - } - } } diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 50b1a0664b9a..6c4856a4acf8 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -74,8 +74,12 @@ const CACHE_TTL_MS = 30 * 60 * 1000; const directoryCache = new DirectoryCache(CACHE_TTL_MS); /** Clears cached directory entries for all channels or one channel/account scope. */ -export function resetDirectoryCache(params?: { channel?: ChannelId; accountId?: string | null }) { - if (!params?.channel) { +export function resetDirectoryCache(params?: { + cfg: OpenClawConfig; + channel: ChannelId; + accountId?: string | null; +}) { + if (!params) { directoryCache.clear(); return; } @@ -89,7 +93,7 @@ export function resetDirectoryCache(params?: { channel?: ChannelId; accountId?: return true; } return key.startsWith(`${channelKey}:${accountKey}:`); - }); + }, params.cfg); } function normalizeQuery(value: string): string { diff --git a/src/logging/secret-redaction-registry.ts b/src/logging/secret-redaction-registry.ts index 93098e6f5614..bcb0eee97e30 100644 --- a/src/logging/secret-redaction-registry.ts +++ b/src/logging/secret-redaction-registry.ts @@ -1,3 +1,4 @@ +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { escapeRegExp } from "../shared/regexp.js"; const MIN_SECRET_VALUE_LENGTH = 6; @@ -18,12 +19,7 @@ function registerOneSecretValue(value: string): void { return; } registeredValues.set(value, true); - if (registeredValues.size > MAX_SECRET_VALUES) { - const oldest = registeredValues.keys().next().value; - if (oldest !== undefined) { - registeredValues.delete(oldest); - } - } + pruneMapToMaxSize(registeredValues, MAX_SECRET_VALUES); rebuildProbe(); } diff --git a/src/media-understanding/defaults.ts b/src/media-understanding/defaults.ts index ebf261cd642a..5c0bd873873a 100644 --- a/src/media-understanding/defaults.ts +++ b/src/media-understanding/defaults.ts @@ -5,6 +5,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import { providerSupportsCapability } from "../../packages/media-understanding-common/src/provider-supports.js"; import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { buildMediaUnderstandingManifestMetadataRegistry } from "./manifest-metadata.js"; import { normalizeMediaExecutionProviderId, @@ -37,10 +38,7 @@ function cacheConfigRegistry( !configRegistryCache.has(key) && configRegistryCache.size >= MAX_CONFIG_REGISTRY_CACHE_ENTRIES ) { - const oldestKey = configRegistryCache.keys().next().value; - if (oldestKey) { - configRegistryCache.delete(oldestKey); - } + pruneMapToMaxSize(configRegistryCache, MAX_CONFIG_REGISTRY_CACHE_ENTRIES - 1); } configRegistryCache.set(key, registry); return registry; diff --git a/src/media/playback-transcode.ts b/src/media/playback-transcode.ts index 4567d6d256fd..aa3e6cf141f5 100644 --- a/src/media/playback-transcode.ts +++ b/src/media/playback-transcode.ts @@ -6,6 +6,7 @@ import { maxBytesForKind, type MediaKind } from "@openclaw/media-core/constants" import { extensionForMime, normalizeMimeType } from "@openclaw/media-core/mime"; import { fileStore } from "../infra/file-store.js"; import { openLocalFileSafely } from "../infra/fs-safe.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { withTempWorkspace } from "../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; @@ -223,18 +224,6 @@ function playbackSourceIdentityMatches( ); } -function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { - map.delete(key); - while (map.size >= maxEntries) { - const oldestKey = map.keys().next().value as K | undefined; - if (oldestKey === undefined) { - break; - } - map.delete(oldestKey); - } - map.set(key, value); -} - function readPlaybackInspection(cacheKey: string): PlaybackInspection | undefined { const inspection = playbackInspections.get(cacheKey); if (inspection) { @@ -244,7 +233,9 @@ function readPlaybackInspection(cacheKey: string): PlaybackInspection | undefine } function cachePlaybackInspection(cacheKey: string, inspection: PlaybackInspection): void { - setBoundedMapEntry(playbackInspections, cacheKey, inspection, MAX_PLAYBACK_ENTRIES.inspections); + playbackInspections.delete(cacheKey); + playbackInspections.set(cacheKey, inspection); + pruneMapToMaxSize(playbackInspections, MAX_PLAYBACK_ENTRIES.inspections); } function playbackInspectionCacheKey(params: { @@ -697,7 +688,9 @@ export async function resolvePlaybackTranscode( }, () => { playbackJobs.delete(operationKey); - setBoundedMapEntry(playbackFailures, operationKey, Date.now(), MAX_PLAYBACK_ENTRIES.failures); + playbackFailures.delete(operationKey); + playbackFailures.set(operationKey, Date.now()); + pruneMapToMaxSize(playbackFailures, MAX_PLAYBACK_ENTRIES.failures); }, ); return { kind: "preparing" }; diff --git a/src/plugin-sdk/approval-reaction-runtime.ts b/src/plugin-sdk/approval-reaction-runtime.ts index 819c1eff007e..2c180df19689 100644 --- a/src/plugin-sdk/approval-reaction-runtime.ts +++ b/src/plugin-sdk/approval-reaction-runtime.ts @@ -8,6 +8,7 @@ import { type ExecApprovalPendingReplyParams, type ExecApprovalReplyDecision, } from "../infra/exec-approval-reply.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { PluginApprovalRequest } from "../infra/plugin-approvals.js"; /** * @deprecated Compatibility subpath for shipped approval reaction helpers. @@ -590,13 +591,7 @@ export function createApprovalReactionTargetStore(params: { memory.delete(key); } } - while (memory.size > params.maxEntries) { - const oldestKey = memory.keys().next().value; - if (!oldestKey) { - return; - } - memory.delete(oldestKey); - } + pruneMapToMaxSize(memory, params.maxEntries); }; return { diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index 7dea2879983f..03ae097b4fc4 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -18,6 +18,7 @@ import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-s import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { ProviderPlugin } from "../plugins/types.js"; import type { ModelProviderConfig } from "./provider-model-shared.js"; @@ -89,12 +90,7 @@ export async function getCachedLiveCatalogValue(params: { if (expiresAt !== undefined) { // Auth-scoped live provider catalogs can vary by token; keep this // process-local cache bounded so discovery cannot grow without limit. - if (liveCatalogCache.size >= LIVE_CATALOG_CACHE_MAX_ENTRIES) { - const oldestKey = liveCatalogCache.keys().next(); - if (!oldestKey.done) { - liveCatalogCache.delete(oldestKey.value); - } - } + pruneMapToMaxSize(liveCatalogCache, LIVE_CATALOG_CACHE_MAX_ENTRIES - 1); liveCatalogCache.set(key, { expiresAt, value, diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index e82a0de597ad..8e283b054b48 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -10,6 +10,7 @@ import { } from "../channels/plugins/setup-contract.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { tryReadJsonSync } from "../infra/json-files.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { PluginCandidate } from "./discovery.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js"; @@ -104,21 +105,11 @@ function rememberInstalledPackageMetadata( ): InstalledPackageMetadata { if (key) { installedPackageMetadataCache.set(key, metadata); - trimBoundedCache(installedPackageMetadataCache, MAX_INSTALLED_PACKAGE_METADATA_CACHE_ENTRIES); + pruneMapToMaxSize(installedPackageMetadataCache, MAX_INSTALLED_PACKAGE_METADATA_CACHE_ENTRIES); } return metadata; } -function trimBoundedCache(cache: Map, maxEntries: number): void { - while (cache.size > maxEntries) { - const oldest = cache.keys().next().value; - if (oldest === undefined) { - break; - } - cache.delete(oldest); - } -} - function buildInstalledPackageMetadataCacheKey( record: InstalledPluginIndexRecord, ): string | undefined { diff --git a/src/plugins/plugin-cache-primitives.ts b/src/plugins/plugin-cache-primitives.ts index 005b9112a1fe..b9a87b842658 100644 --- a/src/plugins/plugin-cache-primitives.ts +++ b/src/plugins/plugin-cache-primitives.ts @@ -1,5 +1,6 @@ // Defines lifecycle-owned cache primitives for plugin metadata. import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; /** Result shape for cache lookups that need to distinguish a miss from cached `undefined`. */ type PluginLruCacheResult = { hit: true; value: T } | { hit: false }; @@ -28,7 +29,7 @@ export class PluginLruCache { typeof value === "number" ? normalizeMaxEntries(value, this.#defaultMaxEntries) : this.#defaultMaxEntries; - this.#evictOldestEntries(); + pruneMapToMaxSize(this.#entries, this.#maxEntries); } clear(): void { @@ -58,17 +59,7 @@ export class PluginLruCache { this.#entries.delete(cacheKey); } this.#entries.set(cacheKey, value); - this.#evictOldestEntries(); - } - - #evictOldestEntries(): void { - while (this.#entries.size > this.#maxEntries) { - const oldestEntry = this.#entries.keys().next(); - if (oldestEntry.done) { - break; - } - this.#entries.delete(oldestEntry.value); - } + pruneMapToMaxSize(this.#entries, this.#maxEntries); } } diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts index 1c28318be0e2..2f2dde34c17f 100644 --- a/src/plugins/tool-descriptor-cache.ts +++ b/src/plugins/tool-descriptor-cache.ts @@ -1,6 +1,7 @@ /** Caches plugin tool descriptors by plugin source, contract names, and runtime context. */ import type { AnyAgentTool } from "../agents/tools/common.js"; import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { JsonObject, ToolDescriptor } from "../tools/types.js"; import type { PluginLoadOptions } from "./loader.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; @@ -186,10 +187,10 @@ export function writeCachedPluginToolDescriptors(params: { !pluginToolDescriptorCacheState.descriptors.has(params.cacheKey) && pluginToolDescriptorCacheState.descriptors.size >= PLUGIN_TOOL_DESCRIPTOR_CACHE_LIMIT ) { - const oldestKey = pluginToolDescriptorCacheState.descriptors.keys().next().value; - if (oldestKey !== undefined) { - pluginToolDescriptorCacheState.descriptors.delete(oldestKey); - } + pruneMapToMaxSize( + pluginToolDescriptorCacheState.descriptors, + PLUGIN_TOOL_DESCRIPTOR_CACHE_LIMIT - 1, + ); } pluginToolDescriptorCacheState.descriptors.set(params.cacheKey, [...params.descriptors]); } diff --git a/src/routing/account-id.ts b/src/routing/account-id.ts index 0dac9096c4ab..e8e08aba0b10 100644 --- a/src/routing/account-id.ts +++ b/src/routing/account-id.ts @@ -1,5 +1,6 @@ // Routing account id helpers normalize account identifiers for route matching. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; export const DEFAULT_ACCOUNT_ID = "default"; @@ -66,13 +67,7 @@ export function normalizeOptionalAccountId(value: string | undefined | null): st function setNormalizeCache(cache: Map, key: string, value: T): void { cache.set(key, value); - if (cache.size <= ACCOUNT_ID_CACHE_MAX) { - return; - } // Bounded FIFO-ish cache avoids unbounded growth from user/channel input // while keeping hot account ids cheap during routing. - const oldest = cache.keys().next(); - if (!oldest.done) { - cache.delete(oldest.value); - } + pruneMapToMaxSize(cache, ACCOUNT_ID_CACHE_MAX); } diff --git a/src/security/safe-regex.ts b/src/security/safe-regex.ts index b20622d8c0c6..d90e9c96b2ba 100644 --- a/src/security/safe-regex.ts +++ b/src/security/safe-regex.ts @@ -1,5 +1,6 @@ // Performs lightweight safe-regex checks for user-supplied patterns. import { expectDefined } from "@openclaw/normalization-core"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; type QuantifierRead = { consumed: number; minRepeat: number; @@ -353,12 +354,7 @@ export function compileSafeRegexDetailed(source: string, flags = ""): SafeRegexC } safeRegexCache.set(cacheKey, result); - if (safeRegexCache.size > SAFE_REGEX_CACHE_MAX) { - const oldestKey = safeRegexCache.keys().next().value; - if (oldestKey) { - safeRegexCache.delete(oldestKey); - } - } + pruneMapToMaxSize(safeRegexCache, SAFE_REGEX_CACHE_MAX); return result; } diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index a4ccadfef709..70600a8a1fb8 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { escapeRegExp } from "../shared/regexp.js"; export type ParsedAgentSessionKey = { @@ -142,13 +143,7 @@ function writeNormalizedSessionKeyCache(raw: string, normalized: string): void { return; } normalizedSessionKeyCache.set(raw, normalized); - while (normalizedSessionKeyCache.size > NORMALIZED_SESSION_KEY_CACHE_MAX_ENTRIES) { - const oldest = normalizedSessionKeyCache.keys().next().value; - if (oldest === undefined) { - return; - } - normalizedSessionKeyCache.delete(oldest); - } + pruneMapToMaxSize(normalizedSessionKeyCache, NORMALIZED_SESSION_KEY_CACHE_MAX_ENTRIES); } function mayContainCasePreservingPeer(raw: string): boolean { diff --git a/src/skills/runtime/session-snapshot.ts b/src/skills/runtime/session-snapshot.ts index 98f771df8d1f..020ecf9d2ebb 100644 --- a/src/skills/runtime/session-snapshot.ts +++ b/src/skills/runtime/session-snapshot.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import { stableStringify } from "@openclaw/normalization-core"; import { redactConfigObject } from "../../config/redact-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { matchesSkillFilter } from "../discovery/filter.js"; import { buildWorkspaceSkillSnapshot } from "../loading/workspace.js"; import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js"; @@ -45,12 +46,7 @@ function fingerprintSkillSnapshotConfig(config: OpenClawConfig): string { function cacheResolvedSkills(cacheKey: string, snapshot: SkillSnapshot): SkillSnapshot { resolvedSkillsCache.set(cacheKey, snapshot.resolvedSkills); - if (resolvedSkillsCache.size > RESOLVED_SKILLS_CACHE_MAX) { - const oldest = resolvedSkillsCache.keys().next().value; - if (oldest !== undefined) { - resolvedSkillsCache.delete(oldest); - } - } + pruneMapToMaxSize(resolvedSkillsCache, RESOLVED_SKILLS_CACHE_MAX); return snapshot; } diff --git a/src/skills/security/scanner.ts b/src/skills/security/scanner.ts index 69193894fc08..62cbf5b9a04b 100644 --- a/src/skills/security/scanner.ts +++ b/src/skills/security/scanner.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { hasErrnoCode } from "../../infra/errors.js"; +import { pruneMapToMaxSize } from "../../infra/map-size.js"; import { isPathInside } from "../../security/scan-paths.js"; import { formatScanEvidence, LITERAL_SECRET_SKILL_CONTENT_RULE } from "./scan-evidence.js"; @@ -114,22 +115,12 @@ function getCachedFileScanResult(params: { } function setCachedFileScanResult(filePath: string, entry: FileScanCacheEntry): void { - if (FILE_SCAN_CACHE.size >= FILE_SCAN_CACHE_MAX) { - const oldest = FILE_SCAN_CACHE.keys().next(); - if (!oldest.done) { - FILE_SCAN_CACHE.delete(oldest.value); - } - } + pruneMapToMaxSize(FILE_SCAN_CACHE, FILE_SCAN_CACHE_MAX - 1); FILE_SCAN_CACHE.set(filePath, entry); } function setCachedDirEntries(dirPath: string, entry: DirEntryCacheEntry): void { - if (DIR_ENTRY_CACHE.size >= DIR_ENTRY_CACHE_MAX) { - const oldest = DIR_ENTRY_CACHE.keys().next(); - if (!oldest.done) { - DIR_ENTRY_CACHE.delete(oldest.value); - } - } + pruneMapToMaxSize(DIR_ENTRY_CACHE, DIR_ENTRY_CACHE_MAX - 1); DIR_ENTRY_CACHE.set(dirPath, entry); } diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index 8b1222890362..b7a99dd561e2 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -17,6 +17,7 @@ import { resolveStateDir } from "../config/paths.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { tryReadJsonSync } from "../infra/json-files.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import { modelCatalogPricingFingerprint, resolveCatalogModelPricing, @@ -293,12 +294,7 @@ function loadModelsJsonCostIndex(options?: { normalizedEntries: null, rawEntries: null, }; - if (modelsJsonCostCacheByAgentDir.size >= MODELS_JSON_COST_CACHE_LIMIT) { - const oldestAgentDir = modelsJsonCostCacheByAgentDir.keys().next().value; - if (oldestAgentDir !== undefined) { - modelsJsonCostCacheByAgentDir.delete(oldestAgentDir); - } - } + pruneMapToMaxSize(modelsJsonCostCacheByAgentDir, MODELS_JSON_COST_CACHE_LIMIT - 1); modelsJsonCostCacheByAgentDir.set(agentDir, modelsJsonCostCache); }