fix(sessions): cache warm transcript reads to avoid per-turn re-parse#90412
Conversation
|
Codex review: needs maintainer review before merge. Reviewed June 15, 2026, 1:18 PM ET / 17:18 UTC. Summary PR surface: Source +936, Tests +1321. Total +2257 across 13 files. Reproducibility: yes. from source and supplied proof, though not from the original Windows Feishu MiniMax environment. Current main opens a session by reading and parsing the JSONL transcript each time, and the PR proof exercises the real session transcript lifecycle with large warm transcripts. Review metrics: none identified. Stored data model Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land this cache-backed transcript fix after required checks complete and maintainers accept the session cache ownership contract, while keeping the separate resource-loader baseline work tracked independently. Do we have a high-confidence way to reproduce the issue? Yes from source and supplied proof, though not from the original Windows Feishu MiniMax environment. Current main opens a session by reading and parsing the JSONL transcript each time, and the PR proof exercises the real session transcript lifecycle with large warm transcripts. Is this the best way to solve the issue? Yes, likely the best current fix shape. Caching inside AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 40f1190c8be1. Label changesLabel justifications:
Evidence reviewedPR surface: Source +936, Tests +1321. Total +2257 across 13 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
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
|
|
The snapshot-keyed warm cache looks solid, especially with the frozen-entry guardrails. One thing I wanted to ask about is the choice to key entirely off |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@byungskers Good question — the snapshot comes from @sallyom sorry for the direct ping — this one is in the sessions area, close to the lifecycle work you've been touching recently. It fixes #83943: the session resource loader re-parsed the whole JSONL transcript on every warm turn, so per-turn cost grew O(n) with transcript length (the reporter measured the loader stage climbing 14.5s → 42.3s). The fix adds a snapshot-keyed warm cache (capped at 8 files) without changing transcript semantics, scoped to the session resource loader + regression tests. ClawSweeper rated it 🐚 platinum / |
|
@byungskers good question — agreed it's worth being explicit for a perf-sensitive path. The snapshot is the 5-tuple Why an external rewrite can't slip through:
To actually slip through you'd need inode reuse and identical There's also a second guard for the concurrent-writer case: Happy to condense this into an inline comment next to |
|
@vincentkoc flagging this since you've been through the agents/session paths today. It's ready (platinum, proof: sufficient) and narrow: the warm session-transcript cache is silently dropped on the embedded prepare-plus-append path — |
loadEntriesFromFile re-read and re-parsed the entire transcript JSONL on every warm SessionManager.open, so the per-warm-turn cost grew O(n) with transcript length (openclaw#83943). Add a bounded (max 8) snapshot-keyed cache: warm opens whose file snapshot still matches reuse frozen, append-only entries via a shallow array copy instead of re-reading and re-parsing. Only current-version transcripts are cached; the cache invalidates on external file changes (replace / another writer).
…warm cache The warm transcript cache deep-freezes entries; returning the shared frozen header made prepareSessionManagerForRun throw when it normalizes header id/cwd on existing-session embedded runs. Clone only the session header (entries[0]) on the read path so callers get a mutable header while message entries stay shared+frozen and the cache keeps its re-parse savings.
prepareSessionManagerForRun rewrites the transcript header out-of-band after open, leaving the cached sessionFileSnapshot stale; the first append then dropped the warm cache and the next embedded turn reparsed the whole transcript. Add SessionManager.syncSnapshotAfterHeaderRewrite() and call it after the recovered-header and normalization rewrites, plus a regression covering prepare+append+warm-open.
|
Land-ready maintainer pass complete. What changed:
Verification on exact head
Known proof gaps: none for the changed session transcript lifecycle. |
|
Final CI follow-up:
No known proof gap remains in the session transcript cache or lock-ownership surface changed by this PR. |
Avoid repeated full JSONL parsing and cloning on every embedded-agent turn by keeping a bounded, validated transcript cache and advancing repair incrementally. The final implementation preserves lock ownership and exact fingerprint validation, publishes only verified writes, handles header rewrites and unterminated JSONL safely, and adds focused regression coverage. Fixes openclaw#83943. Co-authored-by: Alix-007 <[email protected]>
Avoid repeated full JSONL parsing and cloning on every embedded-agent turn by keeping a bounded, validated transcript cache and advancing repair incrementally. The final implementation preserves lock ownership and exact fingerprint validation, publishes only verified writes, handles header rewrites and unterminated JSONL safely, and adds focused regression coverage. Fixes openclaw#83943. Co-authored-by: Alix-007 <[email protected]>
Avoid repeated full JSONL parsing and cloning on every embedded-agent turn by keeping a bounded, validated transcript cache and advancing repair incrementally. The final implementation preserves lock ownership and exact fingerprint validation, publishes only verified writes, handles header rewrites and unterminated JSONL safely, and adds focused regression coverage. Fixes openclaw#83943. Co-authored-by: Alix-007 <[email protected]>
Summary
Fixes #83943.
The session resource loader re-parsed the entire transcript on every warm turn.
loadEntriesFromFileranreadFileSync+ line-by-lineJSON.parseover the whole JSONL each timeSessionManager.openwas called, so the per-warm-turn cost grew O(n) with transcript length. The reporter measured the session-resource-loader stage climbing from ~14.5s → 31.5s → 42.3s across consecutive warm turns of a single session, eventually tripping a WebSocket timeout.What changed
src/agents/sessions/session-manager.ts— add a bounded (max 8) snapshot-keyed cache forloadEntriesFromFile. The cache key is a high-resolution file snapshot (dev/ino/size+ nanosecondmtime/ctimeviastatSync({ bigint: true })); a warm open whose snapshot still matches returns the cached entries without re-reading or re-parsing the file. Only current-version (v3) transcripts are cached — older versions still go through migration with fresh mutable entries, so cache hits never share objects that migrate. Cached entries are deep-frozen and handed out as a shallow array copy (.slice()), so callers cannot mutate the cached prefix and manager appends land on a separate array, while avoiding a per-open deep clone. Appends update the cache incrementally; the cache invalidates when the file snapshot changes (external replace / another writer). AddssyncSnapshotAfterHeaderRewrite()so an out-of-band transcript rewrite can refresh the manager snapshot/cache (see follow-up below).src/agents/embedded-agent-runner/session-manager-init.ts— afterprepareSessionManagerForRunrewrites the on-disk header (the recovered-header and normalization branches), callsyncSnapshotAfterHeaderRewrite()so the manager's snapshot/cache match the rewritten file before the first append.src/agents/sessions/session-manager.test.ts— 9 tests: warm reuse without re-parse, append without stale readback, cache invalidation on external file replacement, invalidation when another writer appends before this manager persists, old-version transcripts still migrating while bypassing the cache, embedded header normalization without re-parse, and (new) keeping the warm cache across an embedded prepare-plus-append so the next warm open does not reparse.Real behavior proof
Behavior addressed:
loadEntriesFromFileno longer re-parses the whole transcript on every warmSessionManager.open. Warm opens of an unchanged current-version session reuse cached entries, eliminating the per-warm-turn O(n) re-parse cost, while session correctness (no stale or shared-mutation, cache invalidation on external change) is preserved.Real environment tested: Local worktree of
origin/mainat baseca9249e357291890125a5a801a5681defbc816ffwith this patch applied, Node v22.22.0. Drove the real exportedloadEntriesFromFilevianode --import tsx— the actual production loader, no mocks.JSON.parsecall count measured by wrapping the global parse around the loop.Exact steps or command run after this patch:
Evidence after fix: Real output driving the production
loadEntriesFromFileover a 4000-entry transcript, 12 warm opens:Observed result after fix:
JSON.parsecalls drop from 48012 (12 × full re-parse) to 4001 (one initial parse; the 11 warm hits do zero parsing) — a 91.7% reduction — and wall time drops from 198.0ms to 45.0ms (~4.4× faster). Every open still returns all 4001 entries, so correctness is preserved.What was not tested: No live end-to-end Windows + Feishu + MiniMax reproduction of the original ~42s stall (that environment is unavailable); the change is in the deterministic transcript loader, exercised directly with the real exported function and disk readback. This PR addresses only the per-warm-turn transcript-growth subpath, not the constant
reload()baseline cost discussed in #82536.Follow-up: embedded prepare-plus-append cache gap (code-review finding)
Code review found that the embedded runner's
prepareSessionManagerForRunrewrites the transcript header afterSessionManager.open, leaving the cachedsessionFileSnapshotdescribing the pre-rewrite file. The first append then took the snapshot-mismatch branch inrememberAppendedSessionEntry, dropped the cache, and the next warm embedded turn reparsed the whole transcript — so the warm cache did not survive the embedded path.Fix: the header rewrite now calls
syncSnapshotAfterHeaderRewrite(), which refreshes the snapshot and cache from the rewritten file, so the first append stays on the incremental cache path.Proof (real
SessionManager+ real on-disk transcript,JSON.parsereadback): new regressionkeeps the warm cache after prepareSessionManagerForRun rewrites then appendsdrives the realSessionManager.open→prepareSessionManagerForRun(real header rewrite to disk) →appendMessage(real append to disk) → another warmSessionManager.open, counting globalJSON.parse:Validation (supplemental)
node scripts/run-vitest.mjs run src/agents/sessions/session-manager.test.ts→Tests 9 passed (9)tsgo:core/tsgo:core:test→ no new errors in changed files;oxfmt→ clean