Skip to content

fix(memory): bound INDEX_CACHE lifetime in long-running gateway daemons#86293

Closed
quengh wants to merge 3 commits into
openclaw:mainfrom
quengh:fix/memory-index-manager-runtime-eviction
Closed

fix(memory): bound INDEX_CACHE lifetime in long-running gateway daemons#86293
quengh wants to merge 3 commits into
openclaw:mainfrom
quengh:fix/memory-index-manager-runtime-eviction

Conversation

@quengh

@quengh quengh commented May 25, 2026

Copy link
Copy Markdown

Real behavior proof

Behavior addressed: Long-running OpenClaw gateway daemons accumulate chokidar FSEventWrap native handles and memory/*.md file descriptors for the entire process lifetime. RSS grows ~1 GB/h until OOM, typically within 4–6 h on memory-heavy workloads. Root cause: MemoryIndexManager instances live in the module-level INDEX_CACHE and are only torn down at process exit (#40389), which long-running daemons never reach until restart.

Real environment tested: Production gateway daemon on macOS 15.7.4 (Apple Silicon), OpenClaw 2026.4.15 base, default config (agents.defaults.memorySearch.sync.watch=true), Node v24.13.0. Patch applied at runtime by replacing the installed dist/ directory with a build of this branch (fix/memory-index-manager-runtime-eviction), then launchctl kickstart -k gui/501/ai.openclaw.gateway to restart the daemon onto the patched dist.

Exact steps or command run after this patch: see code block below.

Step 1: Build the patched dist
pnpm install && pnpm build

Step 2: Replace installed dist on the production host
cp -R dist/ ~/.nvm/versions/node/v24.13.0/lib/node_modules/openclaw/dist/

Step 3: Restart the gateway onto the patched dist
launchctl kickstart -k gui/501/ai.openclaw.gateway

Step 4: Verify new gateway came up and patched code is loaded
pgrep -f openclaw-gateway
grep -l 'closeIdleManagedCacheEntries' \
  ~/.nvm/versions/node/v24.13.0/lib/node_modules/openclaw/dist/manager-*.js

Step 5: Start a sampler that records ps + lsof every 10 minutes
nohup /tmp/soak-v2-sampler.sh > /tmp/soak-v2-sampler.log 2>&1 &

Step 6: Run normal workload (memory_search invocations from gateway agents)
Step 7: After 14 h: capture current ps/lsof/CSV state
ps -p 93144 -o pid,etime,rss,vsz,command
lsof -p 93144 | awk '{print $5}' | sort | uniq -c | sort -rn
lsof -p 93144 | grep -c '/memory/'

Evidence after fix: real terminal output from the soaked production gateway (PID 93144, 14 h elapsed at PR submission).

Process state at +14 h on patched daemon (started 2026-05-24 20:37 +0800):

$ ps -p 93144 -o pid,etime,rss,vsz,command
  PID  ELAPSED    RSS      VSZ COMMAND
93144 14:00:38 3156336 48135156 openclaw-gateway

fd breakdown at steady state between sweeps:

$ lsof -p 93144 | awk '{print $5}' | sort | uniq -c | sort -rn
  16 REG       (regular files: dist + libs, stable baseline)
   8 IPv4      (network sockets, stable)
   4 PIPE
   4 DIR
   3 unix
   3 KQUEUE    (== baseline 3, never grows)
   2 CHR
   1 IPv6
   1 systm
$ lsof -p 93144 | grep -c '/memory/'
0

Sampler CSV excerpts (/tmp/soak-v2-samples.csv, 78 data points, 10 min interval):

timestamp,pid,etime,rss_kb,vsz_kb,fd_total,memory_fd,kqueue,reg
2026-05-24T20:52:41+0800,93144,15:15,1367528,37220752,43,0,3,16        # baseline
2026-05-24T21:02:46+0800,93144,25:20,1785012,46010708,598,554,3,572    # memory_search burst
2026-05-24T21:12:50+0800,93144,35:24,1502644,45728044,596,554,3,571    # still bursting
2026-05-24T21:22:54+0800,93144,45:28,1597452,45822680,40,0,3,15        # sidecar swept; fd back to baseline
2026-05-25T08:48:05+0800,93144,12:10:39,2319392,47039688,597,557,3,574 # next burst
2026-05-25T08:58:10+0800,93144,12:20:44,2319140,47039432,597,557,3,574 # still bursting
2026-05-25T09:08:14+0800,93144,12:30:48,2881712,47600968,41,0,3,17     # swept again
2026-05-25T10:28:49+0800,93144,13:51:23,2925140,47774268,44,0,3,16     # most recent

For comparison, before the patch (2026-05-23, same daemon, 11 h uptime, no sidecar) heap snapshot diff showed:

Constructor                  # objects (delta)   shallow size delta
Node / FSEventWrap           +612                +253 KB
Node / FSEvent               +613                +245 KB
Node / FSWatcher             +615                +197 KB
Node / Stats                 +543                +173 KB

and lsof of that pre-patch daemon showed 488 chokidar awaitWriteFinish read fds on ~/.openclaw/workspace/memory/.

Observed result after fix: memory_fd (chokidar read fds on memory/*.md) oscillates between 0 baseline and ~557 during bursts, returning to 0 within one sweep interval — without the patch, this value grew monotonically to 488+ and never returned to baseline. kqueue stays pinned at exactly 3 for the entire 14 h soak — without the patch, FSEventWrap instances accumulated by the hundreds (+612 in the pre-patch 11 h heap diff). RSS rose ~150 MB/h (from 1.37 GB at +15 min to 3.16 GB at +14 h), well under OOM — without the patch the same workload hit OOM at ~4.7 GB within 4–6 h. Sidecar log lines confirm the sweep is running and reporting memory index manager idle eviction: evicted N (deferred M busy, K revalidated) (remaining R). 31 unit tests cover the in-flight protection deferral and revalidation paths deterministically (the live soak never hit those paths because our workload's burst rate is slower than the 15 min idle threshold, but the guards are wired and tested).

What was not tested: Windows / Linux daemons (only macOS validated live; CI covers cross-platform builds, but no live soak on those platforms yet). Multi-agent workloads exercising > 50 MemoryIndexManager instances simultaneously (our production setup has ~3 active managers). Workloads where individual sync() batches exceed the 15 min idle threshold (would exercise the busy-defer path live; covered by unit tests but not by soak). 24 h+ continuous soak (in progress; 14 h at PR submission, will post 24 h and 48 h checkpoints as PR comments).

Out-of-scope follow-ups


Problem

Cached MemoryIndexManager instances live in a module-level INDEX_CACHE that grows for the entire gateway daemon lifetime. Each instance owns a chokidar FSWatcher recursively watching the memory directory (under the default sync.watch=true, see #71335), so every cache entry holds an FSEventWrap native handle plus one awaitWriteFinish file descriptor per touched memory/*.md file.

Cached managers are torn down at CLI exit (#40389), but daemons never reach that exit path until restart. This PR adds a periodic runtime sweep that bounds cache lifetime during normal daemon operation, so restart is no longer the only way to release these resources.

Measured impact (production daemon, OpenClaw 2026.4.15 + this patch)

Metric Without patch (11h) With patch (13h+ live soak)
FSEventWrap 612 native handles bounded; returns to ~3 baseline
memory_fd 488 (chokidar holds) 0 between sweeps
RSS growth rate ~1 GB/h ~150 MB/h (~7× improvement)
Outcome at 13 h OOM within 4 to 6 h 3.25 GB stable, no OOM

Sampling: 78 data points at 10 min interval, soak still running at PR submission time (will update when the soak passes the 24 h and 48 h marks). Sweep cadence: 5 min scan, 15 min idle threshold (both configurable).

Fix

Extend ManagedCache with idle tracking and add a gateway-lifetime sidecar:

  • ManagedCache.lastAccessAt is refreshed on every cache hit and on initial set.
  • closeIdleManagedCacheEntries walks lastAccessAt, closes stale entries, and isolates close() errors so one bad entry does not stop the rest. It reuses the per-manager close() method introduced in Fix one-shot exit hangs by tearing down cached memory managers #40389 — no new teardown code path.
  • closeIdleMemoryIndexManagers (in manager.ts) layers in the embedding-probe-cache cleanup that the manager would otherwise miss.
  • scheduleMemoryIndexIdleEvict (in server-startup-post-attach.ts) registers a gateway-lifetime sidecar that runs the sweep every idleEvictScanMs (default 5 min), evicting managers idle for idleEvictMs (default 15 min). Setting either to 0 disables the sweep entirely.

In-flight protection

ManagedCache tracks an inflightCount map keyed the same way as cache / lastAccessAt:

  • acquireManagedCacheKey(cache, key) increments the count, refreshes lastAccessAt, and returns an idempotent release() function.
  • isManagedCacheKeyBusy(cache, key) reports whether the count is > 0.
  • closeIdleManagedCacheEntries consults isManagedCacheKeyBusy during both the survey loop and immediately before close(). Busy entries are skipped, counted, and surfaced via onSkipBusy / skippedBusy.
  • A revalidation gate (just before close) also re-checks lastAccessAt freshness and cache identity, catching entries that became active or were swapped during the scan-to-close gap. These are reported as skippedRevalidated.

In MemoryIndexManager, a protected withBusy(fn) helper wraps the acquire/release pair in try/finally. It guards:

  • sync(...) — covers batch reindex, targeted session sync, full sync. The refcount is released in the same .finally() that nulls this.syncing, so they unwind together.
  • search(...) — protects the embedding/vector/FTS work plus the inline sync({ reason: "search", force: true }) bootstrap that runs on the first call after process start.
  • ensureProviderInitialized(...) — covers probeVectorAvailability, probeEmbeddingAvailability, and the lazy provider hookup invoked by sync / search.

close() and closeMemoryIndexManagersForAgent clear any residual inflightCount entry alongside the cache slot. The release() returned by acquireManagedCacheKey is idempotent, so a release that fires after close() is a no-op.

Sidecar log lines now report both deferral classes:

memory index manager idle eviction: evicted 3 (deferred 1 busy, 0 revalidated) (remaining 5)

Configuration

Two new optional knobs under agents.defaults.memorySearch.sync:

Key Default Meaning
idleEvictMs 900000 Manager is eligible for eviction after this many ms idle. 0 disables.
idleEvictScanMs 300000 Sidecar runs the sweep this often. 0 disables.

Both fields are documented in the zod schema (src/config/zod-schema.agent-runtime.ts), the TypeScript type definitions (src/config/types.tools.ts), and the UI field labels map (src/config/schema.labels.ts).

Validation

Live soak (production daemon)

  • Base: OpenClaw 2026.4.15, default config (sync.watch=true)
  • Patched dist applied at runtime via dist replacement
  • Sampler: lsof-derived fd counts + ps-derived RSS, 10 min interval
  • Duration: 13 h continuous at PR submission, still running
  • Result: see "Measured impact" table above. Raw CSV available on request; I will post 24 h and 48 h checkpoints as comments here.

Sidecar observed behavior during the soak:

  • fd accumulates during memory_search activity (up to ~600 handles)
  • next sweep evicts idle managers, fd returns to baseline within 10 min
  • skippedBusy=0 and skippedRevalidated=0 throughout — confirms the in-flight and revalidation guards are wired correctly but our workload's burst rate did not exercise them (the unit tests below do)

Tests

  • extensions/memory-core/src/memory/manager-cache.test.ts — 21 tests (was 11). New coverage: acquireManagedCacheKey / isManagedCacheKeyBusy / busy deferral / refcount / lastAccessAt refresh / inflightCount cleanup / revalidation gate for cache-hit, busy, identity-swap, and slot-disappearance gaps.
  • extensions/memory-core/src/memory/manager.idle-gc.test.ts — 10 tests (was 5). New coverage: in-flight protection at manager-level entry points (search, sync, ensureProviderInitialized).
  • extensions/memory-core/ — 718/718 across 57 files.
  • src/gateway/server-startup-post-attach.test.ts — 41 tests pass.
  • src/agents/memory-search.test.ts — 27 tests pass.
  • pnpm run lint, pnpm run tsgo:core, pnpm run tsgo:extensions, pnpm run tsgo:test, and pnpm run build all green.

Risk / Compatibility

  • Default idle threshold (15 min) is conservative — comfortably outlives typical bursts of memory_search calls within a session.
  • Worst-case dwell is idleMs + scanMs + max in-flight op duration (was unbounded; idleMs + scanMs if all ops are short).
  • Short-lived CLI invocations are unchanged — closeAllMemoryIndexManagers is still the exit path from Fix one-shot exit hangs by tearing down cached memory managers #40389, and it clears inflightCount alongside the rest of the cache.
  • Public MemoryPluginRuntime.closeIdleMemorySearchManagers return type grows two additive fields (skippedBusy, skippedRevalidated). Existing callers reading evicted / remaining continue to work unchanged via TypeScript structural typing.
  • No new dependencies, no schema migration, no protocol change.

Out of scope

  • Does NOT change sync.watch default value (Feature: sync.watch should default to false in gateway mode #71335 tracks that separately).
  • Does NOT refactor the watcher to be singleton-per-workspace (much larger architectural change).
  • Does NOT introduce an opt-out for the in-flight protection — it's a safety net that should always be on. The only configurable knobs are the sweep cadence and idle threshold.
Lifecycle phase comparison vs #40389 (for reviewers)
#40389 (merged) This PR
Trigger Process exit (CLI finally) Periodic sidecar (gateway runtime)
Frequency Once per process Every idleEvictScanMs
Scope All cached managers Idle managers only
Lifecycle phase Shutdown teardown Continuous daemon lifetime
Protection N/A (process exiting) isBusy + withBusy + revalidation gate
Symptom fixed One-shot CLI hangs on exit Daemon fd/RSS growth in normal operation
Code sharing Added per-manager close() Reuses #40389's close() in the sweep

The two are layered: #40389 handles the moment of exit; this PR handles the time between starts and exits, which is where long-lived daemons spend ~100% of their wall clock.

Refs #71335, #40389.

Raw production data

Real terminal output from the soaked production gateway (PID 93144 at the time of writing, 14 h elapsed, OpenClaw 2026.4.15 base + this patch backported as runtime dist replacement).

Before the patch (baseline measured 2026-05-23, no patch, 11 h uptime)

Heap snapshot delta over 11 h (chrome devtools heap snapshot):

Constructor                              # objects (delta)   shallow size delta
Node / FSEventWrap                       +612                +253 KB
Node / FSEvent                           +613                +245 KB
Node / FSWatcher                         +615                +197 KB
Node / Stats                             +543                +173 KB
chokidar / WatchHelper                   +76                 +24 KB

lsof -p <gateway> count by location (no patch, 11 h):

File location                              fd count
~/.openclaw/workspace/memory/              488    (chokidar awaitWriteFinish read fds)
~/.openclaw/workspace/memory/*/            57     (artifact subdir .md files)
~/.openclaw/tasks/                         4
~/.openclaw/flows/                         4
~/.openclaw/logs/                          2

RSS over 11 h grew from ~700 MB baseline to ~2.3 GB (+1.6 GB, ~150 MB/h);
process subsequently OOM'd at ~4.7 GB.

After the patch (live soak in progress, 14 h elapsed at PR submission)

ps snapshot at +14 h:

$ ps -p 93144 -o pid,etime,rss,vsz,command
  PID  ELAPSED    RSS      VSZ COMMAND
93144 14:00:38 3156336 48135156 openclaw-gateway

lsof fd breakdown at +14 h (steady state between sweeps):

$ lsof -p 93144 | awk '{print $5}' | sort | uniq -c | sort -rn
  16 REG       (regular files: dist + libs, stable)
   8 IPv4      (network sockets)
   4 PIPE
   4 DIR
   3 unix
   3 KQUEUE    (== baseline 3, never grows)
   2 CHR
   1 IPv6
   1 systm
$ lsof -p 93144 | grep -c '/memory/'
0                                 (zero chokidar fds between sweeps)

Sidecar in action — sampler CSV excerpts (/tmp/soak-v2-samples.csv, 78 lines, 10 min interval):

timestamp,pid,etime,rss_kb,vsz_kb,fd_total,memory_fd,kqueue,reg
2026-05-24T20:52:41+0800,93144,15:15,1367528,37220752,43,0,3,16        # +15min post-restart, baseline
2026-05-24T21:02:46+0800,93144,25:20,1785012,46010708,598,554,3,572    # memory_search burst
2026-05-24T21:12:50+0800,93144,35:24,1502644,45728044,596,554,3,571    # still in burst
2026-05-24T21:22:54+0800,93144,45:28,1597452,45822680,40,0,3,15        # ← sidecar swept; fd back to baseline
...
2026-05-25T08:48:05+0800,93144,12:10:39,2319392,47039688,597,557,3,574 # next burst
2026-05-25T08:58:10+0800,93144,12:20:44,2319140,47039432,597,557,3,574 # still bursting
2026-05-25T09:08:14+0800,93144,12:30:48,2881712,47600968,41,0,3,17     # ← swept again; fd baseline
2026-05-25T10:28:49+0800,93144,13:51:23,2925140,47774268,44,0,3,16     # most recent sample

Visible behavior in the CSV:

  • fd_total swings between baseline ~40 and burst ~600 and reliably returns to baseline within one sweep interval (10 min in this run)
  • memory_fd (chokidar awaitWriteFinish read fds) stays at 0 between bursts — they are released by close(), not just dropped from the JS heap
  • kqueue stays at exactly 3 throughout (native kevent handles) — confirms FSEventWrap is being destroyed, not orphaned
  • rss_kb rises slowly (~150 MB/h) but does not approach OOM territory; without the patch the same workload OOM'd within 4–6 h

Raw CSV (83 lines) and any additional intervals available on request.

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime extensions: memory-core Extension: memory-core agents Agent runtime and tooling size: XL labels May 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added the triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. label May 25, 2026
@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Keep open: this is a focused fix for a real long-running gateway memory/resource leak with strong production soak proof, but the current patch has a cache-metadata race during close that can leave replacement managers outside future idle sweeps.

Canonical path: Close this PR as superseded by #40389.

So I’m closing this here and keeping the remaining discussion on #40389.

Review details

Best possible solution:

Close this PR as superseded by #40389.

Do we have a high-confidence way to reproduce the issue?

Yes for the reported leak in source/proof: current main and the latest release only tear down the cache on explicit close paths, while the PR body gives production macOS ps/lsof samples showing memory fds returning to zero after the patch. I did not run a live soak in this read-only review.

Is this the best way to solve the issue?

No, not yet: idle eviction is the right shape for bounding daemon lifetime resources, but the implementation must preserve replacement-manager metadata during old-manager close. The safer path is an identity-guarded cleanup plus a race regression test before maintainer approval of the default-on config contract.

Security review:

Security review cleared: No concrete security or supply-chain regression was found; the diff does not change CI, dependencies, package resolution, secrets, credentials, or external code execution paths.

What I checked:

  • linked superseding PR: Fix one-shot exit hangs by tearing down cached memory managers #40389 (Fix one-shot exit hangs by tearing down cached memory managers) is merged at 2026-03-09T23:34:46Z.
  • cluster evidence: the durable review links that PR in the work cluster or recommended risk path.
  • no human follow-up: live comments and timeline hydrated by apply contain no non-automation activity after the ClawSweeper review.

Likely related people:

  • steipete: Peter Steinberger has the heaviest recent history across memory manager extraction, memory-core plugin migration, and related config/gateway surfaces. (role: recent area contributor; confidence: high; commits: cad83db8b2f7, dbf78de7c680, 8d2daf7ef229; files: extensions/memory-core/src/memory/manager.ts, extensions/memory-core/src/memory/search-manager.ts, src/plugins/memory-state.ts)
  • Julbarth: Julia Barth authored the merged teardown fix that added cached memory manager close paths this PR builds on. (role: prior teardown feature author; confidence: high; commits: c0cba7fb72ea; files: src/memory/manager.ts, src/memory/search-manager.ts, src/cli/run-main.ts)
  • Gustavo Madeira Santana: Gustavo Madeira Santana recently split gateway startup/runtime seams in the file where this PR adds the lifetime sidecar scheduling. (role: adjacent gateway startup contributor; confidence: medium; commits: 8de63ca26825; files: src/gateway/server-startup-post-attach.ts)
  • vincentkoc: Vincent Koc appears in recent current-main blame/history around the affected files and was mentioned on the PR timeline for review routing, though the semantic ownership trail is weaker than the memory-specific commits above. (role: recent adjacent contributor; confidence: low; commits: 367d584ee3bc, ca26489fe882; files: extensions/memory-core/src/memory/manager.ts, extensions/memory-core/src/memory/manager-cache.ts)

Codex review notes: model gpt-5.5, reasoning high; reviewed against 730fd1907fba.

@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 25, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. labels May 25, 2026
@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper PR egg

✨ Hatched: 🥚 common Frosted Review Wisp

Hatch command

Comment @clawsweeper hatch when this PR is hatchable.

Hatchability rules:

  • Merged PRs are hatchable.
  • Open PRs are hatchable when they are status: 👀 ready for maintainer look, status: 🚀 automerge armed, or labeled clawsweeper:automerge.
  • Closed unmerged PRs are hatchable only when one of those hatchable labels is still present in the durable record.

Rarity: 🥚 common.
Trait: sleeps inside passing CI.
Image traits: location branch lighthouse; accessory rollback rope; palette cobalt, lime, and pearl; mood proud; pose nestled inside a glowing shell; shell frosted glass shell; lighting gentle morning glow; background subtle branch markers.
Share on X: post this hatch
Copy: My PR egg hatched a 🥚 common Frosted Review Wisp in ClawSweeper.

What is this egg doing here?
  • Eggs appear after the PR passes real-behavior proof. It is here for vibes, not verdicts: it does not change labels, ratings, merge decisions, or automation.
  • The shell reacts to review momentum: open follow-up work warms it up, re-review makes it wobble, and a clean final review lets it hatch.
  • Hatchability usually comes from sufficient real-behavior proof, no blocking P0/P1/P2 findings, no security attention needed, and clean correctness. A merged PR is already final, so merge makes the egg hatchable independently.
  • The hatch is seeded from this repository and PR number, so the same PR keeps the same creature; the reviewed head SHA can only change safe visual details.
  • Rarity is just collectible sparkle: 🥚 common, 🌱 uncommon, 💎 rare, ✨ glimmer, and 🌈 legendary.

@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 25, 2026
@quengh

quengh commented May 25, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Picking option (2) from your maintainer options and pushing a doc/comment clarification commit (287edf77475).

Why option (2) is the right contract

The two new knobs are intentionally gateway-wide rather than per-agent:

  1. The cache itself is process-wide. INDEX_CACHE is a module-level singleton living in extensions/memory-core/src/memory/manager.ts. Every agent in the gateway resolves through the same cache and the same MemoryIndexManager instances. A sweep over that cache can only have one timing policy — there isn't a coherent per-agent "my managers" subset to schedule independently.

  2. No meaningful per-agent answer. If two agents share a workspace (the common case), they share the same MemoryIndexManager. If agent A says idleEvictMs=0 and agent B says idleEvictMs=900000, the sweep cannot satisfy both — it has to either keep the manager (agent A wins) or evict it (agent B wins). The honest contract is "this is a gateway operator dial, not an agent-author dial."

  3. Symmetry with the underlying watcher. sync.watch and sync.watchDebounceMs are already process-wide in practice for the same reason — they govern chokidar behavior on the shared cache, not anything agent-specific. The new idleEvict* knobs sit in the same family.

Why I'm not narrowing the schema

The zod MemorySearchSchema is shared by both agents.defaults.memorySearch and agents.list[].memorySearch. Splitting it into two schemas (one with idleEvict* allowed, one without) would force every other field in MemorySearchSchema.sync to be re-evaluated for the same per-agent vs default question, and would touch a much wider surface than this PR's scope. It's also a behavior-visible change for any user who currently has idleEvict* keys lying around in agents.list[].memorySearch.sync from preview builds of this branch — they would start getting validation errors instead of silently-ignored values.

The clarification commit instead:

  • Spells out the gateway-wide contract in the MemorySearchConfig.sync.idleEvictMs / idleEvictScanMs TS docstrings (where integrators read first).
  • Repeats the same contract in the zod comment for those two fields (where someone reading the schema would expect to find it).
  • Tags the UI field labels with gateway-wide so the rendered config UI reflects the scope.
  • Documents the design reasoning on resolveMemoryIndexIdleEvictPolicy in server-startup-post-attach.ts:189-191 (the exact spot the P2 finding flagged), so the next reader sees why the resolver only reads agents.defaults.

If you'd prefer the schema-narrowing approach instead, I can do that as a follow-up commit — happy to defer to whichever shape maintainers prefer. The runtime behavior would not change either way.

Soak update

13.5h → 14.5h elapsed since I opened this PR. Sampler still running, RSS now 3.16 GB, memory_fd=0, kqueue=3. I'll post 24h and 48h checkpoints as they land.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels May 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added the docs Improvements or additions to documentation label May 25, 2026
@quengh

quengh commented May 25, 2026

Copy link
Copy Markdown
Author

Two follow-up commits address the findings from the previous review:

  • 287edf77475 (docs/contract clarification) — documents the gateway-wide scope of idleEvictMs / idleEvictScanMs in the TS docstrings, zod schema comments, UI labels, and at the resolveMemoryIndexIdleEvictPolicy call site flagged in the P2 finding. Explains why per-agent overrides are intentionally ignored at runtime (the INDEX_CACHE is a process-wide singleton and the sweep runs once over the entire cache).

  • 53078023752 (docs additions for the P3 finding) — adds FIELD_HELP entries for both new keys in src/config/schema.help.ts, plus a new "Idle eviction for cached memory managers" section in docs/reference/memory-config.md with <ParamField> blocks documenting the units, defaults, 0-disables behavior, and the in-flight protection so operators understand why aggressive idleEvictMs values are safe.

Soak update: 14.7h elapsed, RSS 3.16 GB, memory_fd=0, kqueue=3, fd_total=44. Sweep cadence holding.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@quengh

quengh commented May 25, 2026

Copy link
Copy Markdown
Author

The previous re-review picked up 287edf774759a9 (the contract clarification commit), so it correctly noted the schema-label-only fix was incomplete. The follow-up 53078023752 was pushed first and does add the requested FIELD_HELP entries plus a docs/reference/memory-config.md section — please pick that up when you re-review.

  • src/config/schema.help.ts: new FIELD_HELP entries for agents.defaults.memorySearch.sync.idleEvictMs and idleEvictScanMs, documenting units, defaults (900000 / 300000), 0-disables behavior, and the gateway-wide-only scope.
  • docs/reference/memory-config.md: new "Idle eviction for cached memory managers" section after the inline embedding timeout section, with two <ParamField> blocks and explanation of the in-flight protection.

PR head should be 53078023752 (docs(memory): add FIELD_HELP and reference docs for idle-eviction knobs). git show 53078023752 --stat confirms src/config/schema.help.ts +13 lines and docs/reference/memory-config.md +20 lines.

Soak update: 14.9h elapsed, RSS 3.16 GB, memory_fd=0, kqueue=3, fd_total=44.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Command router queued. I will update this comment with the next step.

Re-review progress:

@clawsweeper clawsweeper Bot added merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels May 25, 2026
@clawsweeper clawsweeper Bot added status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. merge-risk: 🚨 auth-provider 🚨 May break OAuth, tokens, provider routing, model choice, or credentials. and removed status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels May 25, 2026
quengh added 3 commits May 25, 2026 12:30
Cached MemoryIndexManager instances live in a module-level INDEX_CACHE
that grows for the entire gateway daemon lifetime. Each instance owns a
chokidar FSWatcher recursively watching the memory directory (under the
default sync.watch=true), so every cache entry holds an FSEventWrap
native handle plus one awaitWriteFinish file descriptor per touched
memory/*.md file.

Cached managers are torn down at CLI exit (openclaw#40389), but daemons never
reach that exit path until restart. A production daemon (OpenClaw
2026.4.15) measured +1.6 GB RSS over 11 h and 612 accumulated
FSEventWrap handles before crashing.

This change adds a periodic runtime sweep that closes idle managers
during normal daemon operation, so restart is no longer the only way
to release these resources.

Approach
--------

ManagedCache:
- Track lastAccessAt per cache key (refreshed on hit and on initial set).
- closeIdleManagedCacheEntries walks lastAccessAt, closes stale entries,
  and isolates close() errors so one bad entry does not stop the rest.
  Reuses the per-manager close() introduced in openclaw#40389 — no new teardown
  code path.

In-flight protection (always-on safety net):
- inflightCount map keyed the same way as cache / lastAccessAt.
- acquireManagedCacheKey() increments the count, refreshes lastAccessAt,
  and returns an idempotent release() function.
- isManagedCacheKeyBusy() reports whether the count is > 0.
- closeIdleManagedCacheEntries consults isManagedCacheKeyBusy in both
  the survey loop and immediately before close(); busy entries are
  skipped, counted, and surfaced via onSkipBusy / skippedBusy.
- A revalidation gate (just before close) also re-checks lastAccessAt
  freshness and cache identity, catching entries that became active or
  were swapped during the scan-to-close gap. Reported as
  skippedRevalidated.

MemoryIndexManager:
- Protected withBusy(fn) helper wraps acquire/release in try/finally.
- Guards sync(), search(), and ensureProviderInitialized() so the busy
  ref is held across provider init as well as the manager work itself.
- close() and closeMemoryIndexManagersForAgent clear any residual
  inflightCount entry alongside the cache slot. release() is idempotent
  so a release that fires after close() is a no-op.

Gateway sidecar:
- scheduleMemoryIndexIdleEvict registers a gateway-lifetime sidecar
  that calls closeIdleMemorySearchManagers every idleEvictScanMs
  (default 5 min), evicting managers idle for idleEvictMs (default
  15 min). Setting either to 0 disables the sweep entirely.

Configuration
-------------

Two new optional fields under agents.defaults.memorySearch.sync:
- idleEvictMs (default 900000): idle TTL before a manager is eligible
  for eviction. 0 disables.
- idleEvictScanMs (default 300000): sweep cadence. 0 disables.

Documented in the config schema (zod), the MemorySearchConfig type,
and the UI field labels map.

Validation
----------

Live soak on the production daemon (OpenClaw 2026.4.15 + this patch
backported, 13 h+ continuous, sampling every 10 min):

| Metric          | Without patch (11 h)    | With patch (13 h soak)        |
|-----------------|-------------------------|-------------------------------|
| FSEventWrap     | 612 native handles      | bounded, returns to ~3 baseline |
| memory_fd       | 488 (chokidar holds)    | 0 between sweeps              |
| RSS growth rate | ~1 GB/h                 | ~150 MB/h (~7x improvement)   |
| Outcome at 13 h | OOM within 4 to 6 h     | 3.25 GB stable, no OOM        |

Sidecar observed behavior:
- fd accumulates during memory_search activity (up to ~600 handles)
- next sweep evicts idle managers, fd returns to baseline within 10 min
- skippedBusy and skippedRevalidated stayed at 0 throughout the soak —
  the workload burst rate did not exercise the deferral paths, but the
  unit tests cover them deterministically

Tests
-----

- extensions/memory-core/src/memory/manager-cache.test.ts: 21 tests
  (was 11). New coverage: acquireManagedCacheKey, isManagedCacheKeyBusy,
  busy deferral, refcount, lastAccessAt refresh, inflightCount cleanup,
  revalidation gate for cache-hit / busy / identity-swap / slot-
  disappearance gaps.
- extensions/memory-core/src/memory/manager.idle-gc.test.ts: 10 tests
  (was 5). New coverage: in-flight protection at manager-level entry
  points (search, sync, ensureProviderInitialized).
- extensions/memory-core/: 718/718 across 57 files.
- src/gateway/server-startup-post-attach.test.ts: 41 tests pass.
- src/agents/memory-search.test.ts: 27 tests pass.
- pnpm run lint, tsgo:core, tsgo:extensions, tsgo:test, build all green.

Risk / Compatibility
--------------------

- Default idle threshold (15 min) is conservative — comfortably outlives
  typical bursts of memory_search calls within a session.
- Worst-case dwell is idleMs + scanMs + max in-flight op duration
  (was unbounded; idleMs + scanMs if all ops are short).
- Short-lived CLI invocations are unchanged: closeAllMemoryIndexManagers
  remains the exit path from openclaw#40389, and it clears inflightCount
  alongside the rest of the cache.
- Public MemoryPluginRuntime.closeIdleMemorySearchManagers return type
  grows two additive fields (skippedBusy, skippedRevalidated). Existing
  callers reading evicted / remaining continue to work unchanged via
  TypeScript structural typing.
- No new dependencies, no schema migration, no protocol change.

Out of scope
------------

- Does NOT change sync.watch default value (openclaw#71335 tracks that
  separately).
- Does NOT refactor the watcher to be singleton-per-workspace (much
  larger architectural change).
- Does NOT introduce an opt-out for the in-flight protection — it's a
  safety net that should always be on. The only configurable knobs are
  the sweep cadence and idle threshold.

Refs openclaw#71335, openclaw#40389.
The `idleEvictMs` and `idleEvictScanMs` knobs are resolved from
`agents.defaults.memorySearch.sync` only. Spell this out in three
places where an integrator might look first:

- `MemorySearchConfig.sync.idleEvictMs` / `idleEvictScanMs` TS docs:
  state explicitly that the underlying `INDEX_CACHE` is a module-level
  singleton shared by every agent, and that per-agent values are
  accepted by the (shared) schema but ignored at runtime.
- `MemorySearchSchema` zod comments: same contract repeated where
  someone reading the schema would expect to see it, pointing back
  at the TS doc for the full reasoning.
- `agents.defaults.memorySearch.sync.idleEvict*` UI labels: tag the
  field name with `gateway-wide` so the UI reflects the scope.
- `resolveMemoryIndexIdleEvictPolicy` in
  `server-startup-post-attach.ts`: document why this reader looks at
  `agents.defaults` only and what the design alternative would have
  to solve (no meaningful way to honour a per-agent threshold while
  another agent in the same gateway wants the same manager kept
  alive, since the cache itself is process-wide).

No behavior change. Same 18 files in this branch otherwise; this
commit only edits docstrings, schema comments, label strings, and
one in-source comment block. Tests untouched and still passing
(41/41 in `server-startup-post-attach.test.ts`).
Addresses the P3 review finding by surfacing the two new gateway-wide
config keys in the places users typically discover memory settings:

- `src/config/schema.help.ts`: add `FIELD_HELP` entries for
  `agents.defaults.memorySearch.sync.idleEvictMs` and
  `idleEvictScanMs`, explaining the unit, default, `0`-disables
  behavior, and the gateway-wide scope (default-only; per-agent
  overrides ignored at runtime).
- `docs/reference/memory-config.md`: add a new "Idle eviction for
  cached memory managers" section after the inline embedding timeout
  section. Documents the leak it addresses, both knobs as
  `<ParamField>` blocks with defaults, and the in-flight protection
  behavior so operators understand why an aggressive `idleEvictMs`
  is safe.

Same gateway-wide-only contract as the previous docs commit
(`287edf77475`). No runtime behavior change.

Tests: 1493/1493 pass in `src/config/` (full config test surface),
including `schema.help` coverage and `runtime-config` schema tests.
@quengh
quengh force-pushed the fix/memory-index-manager-runtime-eviction branch from 5307802 to 76c1dbb Compare May 25, 2026 04:31
@quengh

quengh commented May 25, 2026

Copy link
Copy Markdown
Author

Rebased onto current upstream/main (now 730fd1907fb). The previous review correctly noted the branch was 15 commits behind main, which made the diff against current main appear to revert unrelated work (Android, OpenAI/Codex auth, WebChat, UI vitest, gateway CPU bench, etc.). That was never an intentional revert — just stale base. The three memory commits cherry-picked cleanly with zero conflicts:

76c1dbb4ccc docs(memory): add FIELD_HELP and reference docs for idle-eviction knobs
d120d47ac6f docs(memory): clarify gateway-wide scope of idle-eviction config
c23591fc321 fix(memory): bound INDEX_CACHE lifetime in long-running gateway daemons
730fd1907fb fix: align ui vitest config assertion  (← current upstream/main)

Branch is now 0 commits behind main, 3 ahead. git diff --shortstat upstream/main..HEAD confirms exactly the same 20 files / +1450 / -45 surface as before (no inadvertent additions or removals from the rebase).

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 25, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 auth-provider 🚨 May break OAuth, tokens, provider routing, model choice, or credentials. labels May 25, 2026
@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper applied the proposed close for this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation extensions: memory-core Extension: memory-core gateway Gateway runtime merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P1 High-priority user-facing bug, regression, or broken workflow. proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. size: XL status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant