Skip to content

perf(memory): coalesce + cache session-file listings to cut NFS READDIR load#91081

Closed
amknight wants to merge 5 commits into
mainfrom
ak/session-files-readdir-coalesce
Closed

perf(memory): coalesce + cache session-file listings to cut NFS READDIR load#91081
amknight wants to merge 5 commits into
mainfrom
ak/session-files-readdir-coalesce

Conversation

@amknight

@amknight amknight commented Jun 7, 2026

Copy link
Copy Markdown
Member

Summary

Repeated session-directory scans (listSessionFilesForAgent) issue a fs.readdir(dir, { withFileTypes: true }) per call with zero caching or coalescing across call sites. On networked filesystems (NFS) this is expensive: resolving each entry's type turns one logical scan into roughly one attribute fetch per session file, and several subsystems (startup catch-up, incremental sync, dreaming, qmd export) scan the same agent sessions dir in close succession — producing redundant READDIR/GETATTR load.

This adds an in-flight dedupe + short-TTL snapshot cache around listSessionFilesForAgent:

  • Coalescing (the zero-staleness win) — concurrent callers join a single in-flight Promise, so a burst of scans triggers one READDIR instead of N. Each caller still sees a result that reflects the directory at read time; there is no staleness window for the concurrent case.
  • Short-TTL snapshot — a resolved listing is reused briefly between scans. The cache is keyed by the resolved sessions directory (folds in OPENCLAW_STATE_DIR, and lines up with path.dirname(update.sessionFile) for precise invalidation).
  • Per-caller copies — every public return hands back a .slice() of the snapshot, so the cached array is never shared by reference. A caller mutating its result can't corrupt the cache or another caller's view; the coalescing/caching is invisible to callers.
  • Event-driven invalidation (the correctness path) — a single lazy subscription to the canonical onSessionTranscriptUpdate event drops the affected directory's snapshot. Every in-process mutation to a session-owned path (append, compaction, rewrite, chat inject, command exec) emits this event, and archive rotation (archiveFileOnDisk) already emits through it too, so one canonical hook covers all in-process write paths without scattered per-path invalidation.
  • TTL as a backstop onlySESSION_FILES_LISTING_TTL_MS = 1_000 (1s) bounds staleness for writers that never reach our event bus (e.g. another node sharing the NFS sessions dir, or low-frequency archive prunes). Correctness for in-process writes comes from the event invalidation above, not the TTL; 1s keeps the eventual-consistency window for out-of-band writers tight.
  • Failed scans are never cached — read results are a discriminated union ({ ok: true; files } | { ok: false }). A transient fs.readdir failure (NFS EIO/ESTALE/EACCES/timeout) is returned to the one caller as empty but not published into the snapshot, so the next call retries instead of serving a false-empty session set for the TTL window. A legitimately absent dir (ENOENT, the normal "no sessions yet" state) is still cached as empty, since the first session write emits an invalidation event.

The cache is bounded (64 dirs), module-lifecycle-owned, and race-safe: a completed read only publishes its snapshot if its entry is still the active one, so an invalidation or newer read during the await wins. A resetSessionFilesListingCache() helper exists for test isolation and is intentionally kept out of the package's public re-export surface.

withFileTypes removal (to cut the per-entry NFS lstat amplification) is deferred to a focused follow-up.

Scope — what reads are covered

The cache wraps a single physical read: the fs.readdir(dir, { withFileTypes: true }) inside listSessionFilesForAgent, where dir is the resolved per-agent sessions directory (…/agents/<agentId>/sessions). That withFileTypes scan is the expensive NFS operation — one logical scan fans out into roughly one attribute fetch per session file.

Four in-process memory-engine call sites repeatedly scan the same sessions dir in close succession (the "repeated scans" pattern from the analysis); all now route through the coalesce/cache layer:

Caller Path What triggers it
manager-sync-ops.ts:1188 markSessionStartupCatchupDirtyFiles() Startup catch-up — memory manager boot with the sessions source enabled.
manager-sync-ops.ts:1629 incremental/targeted reindex Memory sync cycles — when no explicit targetSessionFiles are passed, it lists the whole dir to build the reindex plan.
dreaming-phases.ts:850 session-ingestion loop Dreaming — iterates agentIds, listing each agent's sessions to batch messages by day.
qmd-manager.ts:2383 exportSessions() qmd export — retention/export pass listing sessions to compute keep/track sets.

These run on overlapping schedules and can fire concurrently, so they previously produced N independent READDIR storms over a single directory. With the cache:

  • Concurrent calls → coalesced into 1 READDIR (in-flight join).
  • Sequential calls within the TTL with no intervening write → served from snapshot, 0 READDIR.
  • After any session write → the onSessionTranscriptUpdate event drops the snapshot, so the next call re-scans.

Out of scope (intentional — focused change): other sessions-dir reads that are one-shot / CLI / diagnostic rather than hot-loop, and are left for a follow-up: dreaming-narrative.ts:901 (separate narrative readdir; the most natural follow-up candidate), cli.runtime.ts:527 (scanSessionFiles CLI diagnostic), session-cost-usage.ts:378 (cost/usage subsystem), dreaming-repair.ts:78 (repair corpus pass). Unrelated scans of the parent agents/ directory (session-utils.ts, agent-list.ts, gateway doctor.ts) use a different key and are untouched.

Verification

Committed regression tests

node scripts/run-vitest.mjs packages/memory-host-sdk/src/host/session-files.test.ts19/19 (12 pre-existing + 7 new). The cache tests count real scans by spying on fsPromises.readdir:

  • concurrent coalescing — two un-awaited concurrent calls ⇒ readdir called exactly once.
  • cached-within-TTL — a second call after writing a new file without emitting an event still returns the prior snapshot ⇒ 1 scan.
  • refresh-after-TTL — with vi.useFakeTimers({ toFake: ["Date"] }), advancing Date past the 1s TTL forces a re-scan ⇒ 2 scans, fresh listing.
  • event invalidationemitSessionTranscriptUpdate({ sessionFile }) drops the snapshot ⇒ next call re-scans, fresh listing.
  • failed-scan not cached (new, addresses review)readdir rejected once with EIO ⇒ first call returns [], a second call within the TTL re-scans (2 scans) and recovers the real listing.
  • absent-dir cached (new) — a missing sessions dir (ENOENT) is cached as empty ⇒ 1 scan across two calls.
  • isolated per-caller copies (new) — across a concurrent burst (in-flight originator + joiner) and a sequential cache hit, callers mutate their returned arrays (.length = 0, .push); a later read still returns the correct listing ⇒ caller mutation can't corrupt the snapshot, all from 1 scan.

Other proof:

  • Consumer suites green: manager-sync-ops.startup-catchup, manager-targeted-sync, manager-session-sync-state, qmd-manager, manager.fts-only-reindex, manager-source-state, dreaming-phases, manager-sync-yield, session-files-yield.
  • Typecheck: pnpm tsgo:core and pnpm tsgo:test:packages exit 0. node scripts/run-oxlint.mjs clean; oxfmt --check clean.

Concurrency chaos / fuzz hammering (dev-time stress; not committed)

A seeded invariant-based harness drove randomized interleavings of concurrent read bursts + real file create/delete + onSessionTranscriptUpdate events against the live cache, asserting:

  • coalescing by physical READDIR count — a fsPromises.readdir spy confirms a synchronous burst of N concurrent reads issues ≤1 physical scan (a fresh read, or 0 when served from a live snapshot), never one per caller, and every caller observes the same content (compared by value, since each now receives its own array copy);
  • no phantom files — every returned name is a real, valid session file that actually existed;
  • eventual consistency — after a final invalidation, the listing equals on-disk ground truth exactly;
  • cache-bound correctness — with agent count > the 64-dir bound, evicted entries re-read fresh (never stale/wrong).

Ran against the current design (1s TTL, per-caller copies, READDIR-count coalescing proof) at 20,000 iterations × burst 32 × 300 agents across 5 seeds — all green. (Kept as a local stress tool rather than a committed suite to avoid a long-running test in CI; the committed unit tests above are the durable regression guard.)

Mutation testing (dev-time; confirms the tests actually catch breakage)

Each cache invariant was corrupted one at a time in session-files.ts; the unit + chaos suites were re-run to confirm they kill the mutant, then the source was restored (verified clean via git diff):

Mutant Killed by Result
re-cache failed scan (the bug this PR's fix removes) unit ✅ killed
drop event-driven invalidation unit + chaos ✅ killed
TTL constant → 0 (cache disabled) unit ✅ killed
disable TTL cache read (never serve snapshot) unit ✅ killed
no coalesce-join (no shared in-flight) unit + chaos ✅ killed
treat ENOENT as failure (stop caching empty dirs) unit ✅ killed
drop .slice() on in-flight originator return unit ✅ killed
drop .slice() on coalesced joiner return unit ✅ killed
drop .slice() on cached-snapshot return unit ✅ killed
drop race-guard on publish ⚪ survives (expected)
max-dirs bound 64 → 100000 ⚪ survives (perf-only)

9/9 correctness mutants killed, 0 unexpected survivors. The surviving drop-race-guard is expected: reads replace the map entry rather than mutating it, so a stale completion writing into an orphaned entry is harmless — the guard is documented defensive intent, not load-bearing. max-dirs is a perf knob, not a correctness property.

…DDIR load

Add an in-flight dedupe + short-TTL (5s) snapshot cache around
listSessionFilesForAgent, keyed by the resolved sessions directory.
Concurrent scans (startup catch-up, incremental sync, dreaming, qmd
export) now share a single READDIR, and snapshots are invalidated via
the canonical onSessionTranscriptUpdate event (covers append,
compaction, rewrite, inject, command exec, and archive rotation). The
TTL is only a backstop for out-of-process writers on a shared NFS dir.
@openclaw-barnacle openclaw-barnacle Bot added size: S maintainer Maintainer-authored PR labels Jun 7, 2026
@clawsweeper

clawsweeper Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 7, 2026, 10:49 PM ET / 02:49 UTC.

Summary
The PR wraps listSessionFilesForAgent in an in-flight coalescer and bounded 1s snapshot cache with transcript-event invalidation plus regression tests.

PR surface: Source +124, Tests +133. Total +257 across 2 files.

Reproducibility: yes. at source level, but not with live NFS proof: current main and v2026.6.1 call fs.readdir on every listSessionFilesForAgent invocation, and multiple memory callers can hit that helper close together.

Review metrics: 2 noteworthy metrics.

  • Snapshot TTL: 1s after successful scan. This is the intentional session-state freshness window maintainers must accept for shared storage and out-of-process writers.
  • Cache capacity: 64 resolved session dirs. The process-local cache is bounded, which matters because this hot path is keyed by agent session directories.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🌊 off-meta tidepool
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • [P2] Record maintainer acceptance of the 1s shared-storage freshness window, or adjust the patch to in-flight coalescing only before merge.

Risk before merge

  • [P1] Memory sync, dreaming ingestion, and qmd export can reuse a session-file snapshot for up to 1s when no transcript event fires, so out-of-process writers or prunes on shared storage may be hidden briefly compared with current per-call reads.
  • [P1] The performance proof in the PR body is helper-level readdir counting plus local stress claims, not direct NFS or syscall-level before/after measurement; maintainers need to accept that proof scope before merge.
  • [P1] The complementary destructive prune-on-failed-scan protection remains in fix(memory): do not prune session index from a failed directory scan #91091; this PR avoids caching failed scans but does not make every consumer distinguish failed enumeration from an authoritative empty listing.

Maintainer options:

  1. Accept the bounded freshness window
    Memory owners can merge as-is if they accept the 1s out-of-process writer window and the helper-level performance proof as sufficient for this hot path.
  2. Keep only zero-staleness coalescing
    If strict shared-storage freshness matters, remove the post-read snapshot reuse and keep only the in-flight dedupe behavior.
  3. Require storage-level proof
    If the performance claim must be proven below the helper layer, add NFS or syscall-level before/after evidence before merge.

Next step before merge

  • [P2] No narrow automated repair is indicated; the remaining action is maintainer judgment on the protected PR's session-state freshness and performance-proof tradeoff.

Security
Cleared: The diff only changes memory session listing code and colocated tests; it does not touch dependencies, workflows, secrets, package metadata, or other supply-chain surfaces.

Review details

Best possible solution:

Land this shape only if memory owners accept the bounded 1s shared-storage freshness tradeoff; otherwise keep the zero-staleness in-flight coalescing and defer TTL reuse or require stronger storage-level proof.

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

Yes at source level, but not with live NFS proof: current main and v2026.6.1 call fs.readdir on every listSessionFilesForAgent invocation, and multiple memory callers can hit that helper close together.

Is this the best way to solve the issue?

Acceptable but not purely mechanical: in-flight coalescing is the clean zero-staleness win, while the 1s snapshot cache is a maintainer-owned freshness tradeoff for shared-storage setups.

AGENTS.md: found and applied where relevant.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 4780546c124d.

Label changes

Label justifications:

  • P2: This is a normal-priority memory performance improvement with bounded but real session-state review risk.
  • merge-risk: 🚨 session-state: Merging can briefly stale session-file discovery for memory sync, dreaming, and qmd export when changes do not emit transcript events.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🌊 off-meta tidepool and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Not applicable: The external-contributor proof gate does not apply to this maintainer-labeled MEMBER PR; the body reports targeted tests and stress checks, while storage-level NFS proof remains a maintainer-risk decision.
Evidence reviewed

PR surface:

Source +124, Tests +133. Total +257 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 128 4 +124
Tests 1 134 1 +133
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 262 5 +257

What I checked:

Likely related people:

  • steipete: Commit eebce9e introduced packages/memory-host-sdk/src/host/session-files.ts with listSessionFilesForAgent; commit cad83db introduced the memory manager sync module that consumes it. (role: introduced helper and memory-engine refactor history; confidence: high; commits: eebce9e9c7cb, cad83db8b2f7; files: packages/memory-host-sdk/src/host/session-files.ts, extensions/memory-core/src/memory/manager-sync-ops.ts)
  • vincentkoc: Available blame on current main attributes the current listing helper and central callers to commit 0f855ea, and shortlog shows substantial recent touches across the memory paths sampled. (role: recent area contributor; confidence: medium; commits: 0f855ea71acc, 2e08f0f4221f; files: packages/memory-host-sdk/src/host/session-files.ts, extensions/memory-core/src/memory/manager-sync-ops.ts, extensions/memory-core/src/dreaming-phases.ts)
  • vignesh07: History shows Vignesh Natarajan authored recent dreaming/session ingestion changes that call the listing helper and share the freshness invariant. (role: adjacent memory-session ingestion contributor; confidence: medium; commits: 8cea63c61b83, 5291a2cfd1be; files: extensions/memory-core/src/dreaming-phases.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added 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. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. labels Jun 7, 2026
@clawsweeper clawsweeper Bot added 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: 🐚 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. labels Jun 7, 2026
@amknight
amknight force-pushed the ak/session-files-readdir-coalesce branch from f9941ff to cd31200 Compare June 7, 2026 05:00
A transient fs.readdir failure (e.g. NFS EIO/ESTALE) was caught as [] and
published into the TTL snapshot, so one blip could be coalesced across
callers and reused as a false-empty session set for the whole TTL window
- making sync/export/dreaming see no session files and risk pruning. Read
results are now a discriminated union: a failed scan is returned to the
caller as empty but never cached, so the next call retries; a legitimately
absent dir (ENOENT) is still cached as empty. Adds regression tests for
the failed-scan retry and the absent-dir caching paths.

Addresses Codex/ClawSweeper review on PR #91081.
@clawsweeper clawsweeper Bot added 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 Jun 7, 2026
@amknight

amknight commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

/clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 7, 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:

@amknight
amknight requested review from steipete and vincentkoc June 8, 2026 02:40
@amknight
amknight marked this pull request as ready for review June 8, 2026 02:40
@joshavant joshavant self-assigned this Jun 9, 2026
@joshavant

Copy link
Copy Markdown
Contributor

Maintainer Follow-Up Draft for PR #91081

PR: #91081

Summary

The overall direction in #91081 looks reasonable: coalescing concurrent
listSessionFilesForAgent() calls is a clean zero-staleness performance win, and
the short-lived listing snapshot is a bounded tradeoff for reducing repeated
session-directory scans on expensive filesystems.

For this to be release-grade, though, the PR should make the cache correctness
story more explicit and more enforceable. The important invariant is not only
that the cache mechanics work; it is that every in-process mutation that can
change the per-agent sessions directory either invalidates the cache or is
handled by a caller that can tolerate a stale/failed listing.

Main Concern

The current PR has strong helper-level proof, but it does not yet show a full
audit of session-directory writers and pruners that do not emit transcript
events.

The PR body acknowledges this risk by treating the 1s TTL as a backstop for
writers that never reach the event bus, including another process sharing the
sessions dir or low-frequency archive prunes. That is a reasonable bounded
fallback, but it is not the same as proving that OpenClaw's own in-process
writers and pruners are covered.

I would like the release-grade version of this change to include an explicit
audit of every in-process path that can create, rename, delete, archive, prune,
or repair files under:

state/agents/<agentId>/sessions

The audit should distinguish:

  • paths that emit emitSessionTranscriptUpdate() with a session file or agent id
  • paths that call a direct cache invalidation helper, if one is added
  • paths that are intentionally out of scope because they are CLI/diagnostic,
    one-shot, cross-process, or already safe under the TTL
  • paths that can affect destructive consumers and therefore need stronger
    handling than "eventual consistency within 1s"

Why #91091 Matters

#91091 is conceptually important because it handles the consumer-side failure
mode that #91081 should not be expected to solve by itself.

#91081 prevents amplification: a transient readdir failure should not be
cached and replayed to later callers.

#91091 prevents destructive interpretation: memory sync should not treat a
failed scan as an authoritative empty sessions directory and prune indexed
session rows.

Those are different layers. Even after #91081, the compatibility API still
returns [] to the immediate caller on a failed scan. That is acceptable for
read-only callers, but unsafe for consumers that use the listing to delete or
prune state. The cleaner long-term contract is:

  • ordinary callers can keep using listSessionFilesForAgent() if [] is safe
  • destructive callers must use an authoritative scan result such as
    { ok: true, files } | { ok: false }
  • pruning should only happen after an authoritative successful enumeration

I would treat #91091, or an equivalent consumer-side guard, as part of the
release-grade story for #91081.

Ongoing Maintenance Risk

Even if the current writer/pruner audit passes, this remains an ongoing
development risk unless the invariant is made structural.

A future change can easily add a new fs.rename, fs.rm, fs.unlink, or direct
write path under an agent sessions directory and forget to emit a transcript
event. The cache would still pass its local tests, but memory sync, dreaming,
qmd export, or another consumer could briefly see a stale listing. The 1s TTL
limits the window, but it should not be the primary correctness mechanism for
OpenClaw-owned in-process mutations.

Suggested Structured Guardrails

  1. Make destructive consumers require authoritative enumeration.

    This is the most important guard. A caller that prunes database rows,
    exported files, vectors, FTS rows, or other durable state should not consume
    a compatibility string[] listing that collapses "failed scan" and
    "authoritatively empty directory" into the same value.

  2. Centralize session-directory mutations.

    Prefer a small session-file mutation API for create/archive/delete/prune
    operations under session transcript directories. That API can emit transcript
    updates or invalidate the listing cache internally. Raw filesystem mutation
    against session dirs should be exceptional and review-visible.

  3. Add an explicit invalidation helper for non-transcript mutations.

    Some mutations may not correspond to a transcript message update. Those
    should call a named helper such as invalidateSessionFilesListingForAgent()
    or invalidateSessionFilesListingForDir() rather than relying on TTL.

  4. Add a guard test or boundary script.

    A lightweight test can scan for suspicious new raw filesystem mutations near
    session transcript dirs outside an approved allowlist. It does not have to be
    perfect to be useful; it should catch accidental new fs.rename, fs.rm,
    fs.unlink, and direct writeFile paths that bypass the event/invalidation
    contract.

  5. Document the invariant at the code site.

    The cache comment should say plainly that OpenClaw-owned in-process
    mutations must emit the event or call explicit invalidation, and that the TTL
    exists only for out-of-process/shared-storage eventual consistency.

Suggested Maintainer Feedback

This looks like a good performance direction, especially the in-flight
coalescing. Before calling it release-grade, I would like the PR to tighten the
correctness story around cache invalidation.

Please add an explicit audit of every in-process writer/pruner that can mutate
state/agents/<agentId>/sessions, including delete/archive/repair/cleanup paths,
and show how each one invalidates this cache or why the 1s TTL is sufficient for
that specific path. The current tests prove the cache mechanics well, but they
do not prove that future or existing non-event mutation paths cannot leave
memory sync, dreaming, or qmd export observing stale listings.

I also think we should make this structural rather than relying on a one-time
audit. At minimum, destructive consumers should not use a compatibility
string[] listing that returns [] for both failed scans and truly empty dirs.
They should use an authoritative scan result and skip pruning on failed
enumeration. That is why #91091, or equivalent logic, is an important companion
to this PR.

Longer term, I would prefer session-directory mutations to go through a small
shared helper that emits/invalidate internally, plus a guard test that flags new
raw filesystem mutations under session transcript dirs outside approved modules.
That gives us a maintainable invariant instead of depending on every future
writer remembering to emit the right event.

Release-Grade Acceptance Bar

I would be comfortable with this direction if the final PR or companion PRs
provide:

Additional Things to Consider

  1. Separate cached listing from authoritative scanning.

    The PR changes listSessionFilesForAgent() itself into a cached
    compatibility helper. That makes cache semantics the default for every
    caller. A more robust contract would expose separate paths: a cached listing
    for benign readers, and a fresh/authoritative scan result for callers that
    prune, delete, or otherwise update durable state.

  2. Canonicalize invalidation keys.

    The cache is keyed by the resolved sessions directory, but invalidation can
    delete by path.dirname(update.sessionFile). That depends on emitters using
    exactly the same path spelling. Equivalent paths through symlinks, relative
    paths, different state-root resolution, or platform casing could miss
    invalidation. Consider canonicalizing cache keys and event paths through the
    same helper before comparison.

  3. Clarify in-flight invalidation semantics.

    If a scan is already in flight and a transcript update arrives before it
    resolves, the PR avoids publishing that stale result to the cache, but the
    awaiting callers still receive the pre-update listing. That may be acceptable
    for benign readers, but it should be documented as "reflects read time" rather
    than "event invalidation makes every returned listing fresh."

  4. Use monotonic time for TTL expiry.

    The cache currently uses wall-clock Date.now(). A clock jump backward can
    extend the cache beyond the intended 1s window, while a jump forward can
    expire early. A monotonic timer would better match TTL semantics.

  5. Decide whether withFileTypes should remain out of scope.

    The PR reduces repeated scans, but each physical scan still uses
    fs.readdir(dir, { withFileTypes: true }), which the PR identifies as the
    expensive NFS-amplifying operation. If writes invalidate frequently, or the
    real workload does not cluster within the TTL/coalescing window, the most
    expensive part remains. Either include measurement showing the cache solves
    the observed workload or consider pulling the withFileTypes removal into
    this release-grade fix.

  6. Validate the bounded-cache behavior.

    The 64-dir cap is plausible, but the committed tests do not appear to cover
    eviction, high-agent scenarios, or hot-directory behavior after eviction.
    This is mainly a performance and lifecycle confidence issue, but the PR
    claims bounded/race-safe behavior, so focused proof would help.

  7. Avoid overstating the invariant in comments.

    The implementation comment says every in-process mutation emits
    onSessionTranscriptUpdate. Unless the PR includes the full writer/pruner
    audit, that statement is stronger than the proof. Prefer wording that states
    the required invariant: OpenClaw-owned session-dir mutations must emit a
    transcript update or call explicit invalidation, and the TTL is only for
    out-of-process/shared-storage eventual consistency.

amknight added a commit that referenced this pull request Jun 10, 2026
A full session sync builds `activePaths` from `listSessionFilesForAgent` and then deletes every indexed session row (files/chunks/vectors/FTS) whose path is not in that listing. The listing swallowed all readdir errors and returned [], so a single transient sessions-dir scan failure (e.g. NFS EIO/ESTALE) was indistinguishable from an empty directory and pruned the entire session memory index, forcing an expensive full re-embed on the next successful scan.

Make the enumeration authoritative for the destructive prune: add `scanSessionFilesForAgent` returning { ok, files } (listSessionFilesForAgent now delegates and is unchanged for callers). The full-enumeration sync passes `scanOk` into `resolveMemorySessionSyncPlan`, which only sets `pruneStaleRows` when a full scan actually read the directory. A failed scan skips the prune (and logs) and leaves the index intact for the next successful enumeration; an authoritatively empty directory still prunes, so legitimately removed sessions (e.g. disk-budget removing the last archive) do not leave orphaned rows.

Latent on main; addresses the consumer-side hazard noted in review of #91081 (which fixed the listing cache from amplifying it).
@amknight

Copy link
Copy Markdown
Member Author

Declining for now, reconsidering the approach

@amknight amknight closed this Jun 11, 2026
amknight added a commit that referenced this pull request Jun 12, 2026
A full session sync builds `activePaths` from `listSessionFilesForAgent` and then deletes every indexed session row (files/chunks/vectors/FTS) whose path is not in that listing. The listing swallowed all readdir errors and returned [], so a single transient sessions-dir scan failure (e.g. NFS EIO/ESTALE) was indistinguishable from an empty directory and pruned the entire session memory index, forcing an expensive full re-embed on the next successful scan.

Make the enumeration authoritative for the destructive prune: add `scanSessionFilesForAgent` returning { ok, files } (listSessionFilesForAgent now delegates and is unchanged for callers). The full-enumeration sync passes `scanOk` into `resolveMemorySessionSyncPlan`, which only sets `pruneStaleRows` when a full scan actually read the directory. A failed scan skips the prune (and logs) and leaves the index intact for the next successful enumeration; an authoritatively empty directory still prunes, so legitimately removed sessions (e.g. disk-budget removing the last archive) do not leave orphaned rows.

Latent on main; addresses the consumer-side hazard noted in review of #91081 (which fixed the listing cache from amplifying it).
amknight added a commit that referenced this pull request Jun 16, 2026
A full session sync builds `activePaths` from `listSessionFilesForAgent` and then deletes every indexed session row (files/chunks/vectors/FTS) whose path is not in that listing. The listing swallowed all readdir errors and returned [], so a single transient sessions-dir scan failure (e.g. NFS EIO/ESTALE) was indistinguishable from an empty directory and pruned the entire session memory index, forcing an expensive full re-embed on the next successful scan.

Make the enumeration authoritative for the destructive prune: add `scanSessionFilesForAgent` returning { ok, files } (listSessionFilesForAgent now delegates and is unchanged for callers). The full-enumeration sync passes `scanOk` into `resolveMemorySessionSyncPlan`, which only sets `pruneStaleRows` when a full scan actually read the directory. A failed scan skips the prune (and logs) and leaves the index intact for the next successful enumeration; an authoritatively empty directory still prunes, so legitimately removed sessions (e.g. disk-budget removing the last archive) do not leave orphaned rows.

Latent on main; addresses the consumer-side hazard noted in review of #91081 (which fixed the listing cache from amplifying it).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer Maintainer-authored PR merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. P2 Normal backlog priority with limited blast radius. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: M status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants