Skip to content

feat: persist session cache across process restarts#27

Merged
venikman merged 7 commits into
mainfrom
devin/1776038929-session-cache-persistence
Apr 14, 2026
Merged

feat: persist session cache across process restarts#27
venikman merged 7 commits into
mainfrom
devin/1776038929-session-cache-persistence

Conversation

@venikman
Copy link
Copy Markdown
Owner

@venikman venikman commented Apr 13, 2026

What

Add optional file-backed persistence to SessionCache so session context survives MCP server restarts. The in-memory LRU Map remains the hot path; persistence is write-through with debounced async flush to <artifactDir>/session-cache.json.

Why

Closes #9 — The MCP stdio server is long-running and is the primary integration surface. Losing session context on restart silently degrades follow-up query quality. CLI users are unaffected (persistence defaults to off).

Type

  • feat — new capability

Changes

  • src/runtime/session-cache.ts: Rewrote SessionCache to accept SessionCacheOptions with optional persistPath. Added idempotent load(sourceHash) that restores sessions from disk only when the snapshot edition matches — stale sessions are discarded. Write-through flush is debounced (500ms), serialized via promise chain, and uses atomic temp+rename. First disk error is logged. Concurrent load() calls are guarded via loadPromise. Entries persisted as ordered tuples to preserve LRU order.
  • src/runtime/runtime.ts: Added persistSessionCache option to FpfRuntimeOptions. Also reads FPF_PERSIST_SESSION_CACHE=true env var as fallback. Calls sessionCache.load(currentSourceHash) at the start of refresh().
  • src/runtime/constants.ts: Added SESSION_CACHE_FILENAME constant (separate from ARTIFACT_FILENAMES since session cache is an optional runtime file, not a built artifact).
  • src/runtime/types.ts: Added persistent: boolean field to RuntimeStatus.sessionCache.
  • src/mcp/tool-contracts.ts: Updated runtimeStatusSchema Zod schema to include persistent field.

Validation

Boundary check

  • Runtime source set is FPF-spec.md only — no additional corpora added
  • No vector database or remote indexing introduced
  • No Python code added
  • MCP tool contracts stay in src/mcp/tool-contracts.ts

Agent metadata

Field Value
Agent Devin
Session https://app.devin.ai/sessions/8fa4d238c77d45b2bfa931f5547007bc
Prompt Merger role — review PR #27, address all review comments, get merge-ready

Requested by: @venikman


Open with Devin

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Original prompt from Stanislau

how our issues doing?

@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copilot AI review requested due to automatic review settings April 13, 2026 00:11
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

Warning

Rate limit exceeded

@devin-ai-integration[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 1 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 4 minutes and 1 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69d160fe-0134-4be3-bc84-18ddaf461278

📥 Commits

Reviewing files that changed from the base of the PR and between d27f8de and d59429c.

📒 Files selected for processing (5)
  • src/mcp/tool-contracts.ts
  • src/runtime/constants.ts
  • src/runtime/runtime.ts
  • src/runtime/session-cache.ts
  • src/runtime/types.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1776038929-session-cache-persistence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements session cache persistence for the FpfRuntime, allowing session states to be saved to and loaded from a JSON file. Key changes include updates to the runtime configuration, the addition of a persistence path in the session cache, and logic to handle asynchronous flushing of cache data to disk. Review feedback suggests optimizing the load method to prevent race conditions and unnecessary re-reads, as well as removing JSON pretty-printing in scheduleFlush to reduce CPU and I/O overhead.

Comment on lines +34 to +55
async load(sourceHash: string): Promise<void> {
this.sourceHash = sourceHash;
if (!this.persistPath) {
return;
}
try {
const raw = await readFile(this.persistPath, 'utf8');
const data: PersistedSessionCache = JSON.parse(raw);
if (data.sourceHash !== sourceHash) {
return;
}
this.entries.clear();
const keys = Object.keys(data.entries);
const start = Math.max(0, keys.length - this.maxSessions);
for (let i = start; i < keys.length; i++) {
const key = keys[i];
this.entries.set(key, data.entries[key]);
}
} catch {
// File missing or corrupt — start fresh
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The load method is called on every query via FpfRuntime.refresh. In its current implementation, it clears the in-memory cache and re-reads from disk every time, which is inefficient and introduces a race condition. If a background flush from a previous query is still writing to disk, load might read stale data and overwrite the newer in-memory state. Additionally, if the sourceHash changes (indicating a new compilation), the existing in-memory sessions should be discarded to avoid using stale context that might refer to outdated node IDs.

Making this method idempotent and ensuring memory is cleared on hash changes fixes these issues.

  async load(sourceHash: string): Promise<void> {
    if (this.sourceHash === sourceHash) {
      return;
    }

    // Clear existing sessions if the compilation hash has changed
    if (this.sourceHash !== undefined) {
      this.entries.clear();
    }
    this.sourceHash = sourceHash;

    if (!this.persistPath) {
      return;
    }
    try {
      const raw = await readFile(this.persistPath, 'utf8');
      const data: PersistedSessionCache = JSON.parse(raw);
      if (data.sourceHash !== sourceHash) {
        return;
      }
      this.entries.clear();
      const keys = Object.keys(data.entries);
      const start = Math.max(0, keys.length - this.maxSessions);
      for (let i = start; i < keys.length; i++) {
        const key = keys[i];
        this.entries.set(key, data.entries[key]);
      }
    } catch {
      // File missing or corrupt — start fresh
    }
  }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both issues addressed in cd1eadcload() is now idempotent (early-returns when sourceHash matches) and clears entries on hash change. The compact JSON suggestion is also applied in the same commit.

Comment on lines +91 to +101
private scheduleFlush(): void {
if (!this.persistPath || !this.sourceHash) {
return;
}
const path = this.persistPath;
const data: PersistedSessionCache = {
sourceHash: this.sourceHash,
entries: Object.fromEntries(this.entries),
};
const json = JSON.stringify(data, null, 2);
this.flushPromise = (this.flushPromise ?? Promise.resolve())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The scheduleFlush method performs synchronous serialization (Object.fromEntries and JSON.stringify) on every set operation. Using pretty-printing (null, 2) for a machine-read cache file adds unnecessary CPU and disk I/O overhead. Furthermore, since this is called on every update, it can lead to a large queue of redundant file writes if sessions are updated frequently.

Suggested change
private scheduleFlush(): void {
if (!this.persistPath || !this.sourceHash) {
return;
}
const path = this.persistPath;
const data: PersistedSessionCache = {
sourceHash: this.sourceHash,
entries: Object.fromEntries(this.entries),
};
const json = JSON.stringify(data, null, 2);
this.flushPromise = (this.flushPromise ?? Promise.resolve())
private scheduleFlush(): void {
if (!this.persistPath || !this.sourceHash) {
return;
}
const path = this.persistPath;
const data: PersistedSessionCache = {
sourceHash: this.sourceHash,
entries: Object.fromEntries(this.entries),
};
const json = JSON.stringify(data);
this.flushPromise = (this.flushPromise ?? Promise.resolve())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied — JSON.stringify(data) without pretty-print in cd1eadc.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f507c3923c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/runtime/runtime.ts
async refresh(force = false): Promise<BuildAudit> {
await mkdir(this.artifactDir, { recursive: true });
const currentSourceHash = await hashFile(this.sourcePath);
await this.sessionCache.load(currentSourceHash);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid reloading session cache on every refresh

With persistence enabled, every query/trace call runs refresh(), and this line reloads cache state from disk before request handling. In the same commit, SessionCache.set() persists asynchronously (scheduleFlush queues writeFile and does not await it), so a follow-up request can arrive before the prior flush completes; load() then replaces in-memory state with stale file contents and drops the just-written session context. This breaks multi-turn continuity precisely in the persistent mode this change introduces.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both issues are already addressed in the latest commit (cd1eadc):

  1. Idempotent load: load() now returns immediately when sourceHash matches the already-loaded hash, so repeated calls from refresh() don't re-read from disk or race with pending flushes.
  2. Hash change clears entries: When sourceHash changes, this.entries.clear() is called before loading new data — stale sessions from the old compilation are discarded.
async load(sourceHash: string): Promise<void> {
  if (this.sourceHash === sourceHash) {
    return; // idempotent — skip re-read
  }
  if (this.sourceHash !== undefined) {
    this.entries.clear(); // discard stale sessions on hash change
  }
  this.sourceHash = sourceHash;
  // ... disk read only on first load or hash change
}

Comment thread src/runtime/session-cache.ts Outdated
Comment on lines +42 to +43
if (data.sourceHash !== sourceHash) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear in-memory sessions on source hash mismatch

When load() sees a persisted snapshot from a different sourceHash, it returns immediately here without clearing existing in-memory entries. In a long-running process where the spec changes, stale session context from the old compilation remains active, and because sourceHash is already updated earlier in load(), later set() calls can flush those stale entries under the new hash, carrying invalid context forward.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cd1eadc — when the persisted sourceHash doesn't match, we now clear entries before returning (the this.sourceHash !== undefined guard at the top handles hash changes by clearing the map). This prevents stale sessions from being flushed under the new hash.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in, file-backed persistence for the runtime SessionCache so MCP server session context can survive process restarts, and exposes persistence status through the runtime status surfaces.

Changes:

  • Reworked SessionCache to support optional persistence (persistPath) with edition-gated loading (sourceHash) and async write-through flushing.
  • Added persistSessionCache runtime option (with FPF_PERSIST_SESSION_CACHE env fallback) and loads the cache during refresh().
  • Extended runtime status types + MCP tool contracts to surface whether session caching is persistent.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/runtime/session-cache.ts Adds persistence, load-from-disk, and async flush behavior to SessionCache.
src/runtime/runtime.ts Wires persistence option/env var and calls sessionCache.load() during refresh().
src/runtime/constants.ts Adds session-cache.json to artifact filenames.
src/runtime/types.ts Extends RuntimeStatus.sessionCache with a persistent flag.
src/mcp/tool-contracts.ts Updates runtimeStatusSchema to include sessionCache.persistent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +41 to +45
}
this.sourceHash = sourceHash;

constructor(private readonly maxSessions = 50) {}
if (!this.persistPath) {
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in cd1eadc — when sourceHash changes, this.entries.clear() is called before setting the new hash:

if (this.sourceHash !== undefined) {
  this.entries.clear();
}
this.sourceHash = sourceHash;

And if the persisted file's hash doesn't match, entries were already cleared by the guard above, so stale context is never reused.

Comment thread src/runtime/runtime.ts
Comment on lines 71 to 75
async refresh(force = false): Promise<BuildAudit> {
await mkdir(this.artifactDir, { recursive: true });
const currentSourceHash = await hashFile(this.sourcePath);
await this.sessionCache.load(currentSourceHash);
const existingSnapshot = await this.loadSnapshot();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in cd1eadcload() is now idempotent: it early-returns when this.sourceHash === sourceHash, so repeated calls from refresh() don't re-read from disk or race with pending flushes. Only the first call (or a sourceHash change) triggers a disk read.

Comment thread src/runtime/constants.ts Outdated
Comment on lines 12 to 14
sessionCache: 'session-cache.json',
} as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in d398c44 — moved sessionCache out of ARTIFACT_FILENAMES into a separate SESSION_CACHE_FILENAME constant. The session cache is an optional runtime file, not a built artifact, so it no longer pollutes getArtifactPresence() or status().artifacts.

Comment on lines +45 to +51
return;
}
try {
const raw = await readFile(this.persistPath, 'utf8');
const data: PersistedSessionCache = JSON.parse(raw);
if (data.sourceHash !== sourceHash) {
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d398c44. Entries are now persisted as Array<[string, RetrievalSessionState]> (ordered tuples) instead of Record<string, ...>, preserving LRU insertion order across JSON round-trips regardless of key format.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d398c44744

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +35 to +36
if (this.sourceHash === sourceHash) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for in-flight cache load before treating hash as loaded

With persistence enabled, concurrent requests on a cold start can lose newly written session state: load() marks sourceHash before the async readFile completes, so a second refresh() call returns early from load() and can call set(); when the first load() resumes, it clears entries and restores the older on-disk snapshot, wiping the second request's in-memory update. This is still possible even with the idempotent hash check, because the check does not distinguish "loaded" from "currently loading".

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this was a real race condition. Fixed in e3afaf0:

  1. Added loadPromise field that stores the in-flight disk read promise
  2. When a concurrent caller sees sourceHash already matches, it awaits loadPromise before returning — so it won't proceed to set() while the disk read is still in progress
  3. Extracted readFromDisk() which now merges disk entries without entries.clear() — it skips keys already present in the map, so entries added by concurrent set() calls between the await boundary are preserved

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3afaf04fa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/runtime/session-cache.ts Outdated
Comment on lines +57 to +58
if (data.sourceHash !== sourceHash) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore stale read results after source hash switches

load() can execute concurrently for different source hashes (for example, one request starts before FPF-spec.md changes and another starts after), but in readFromDisk() this guard only compares data.sourceHash to the method argument from the older call. If this.sourceHash has already been updated by a newer load(), the older in-flight read still merges its entries, reintroducing stale session context for the wrong compilation; a subsequent set() then flushes those stale entries under the new hash.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 5ce7400. After readFile completes, readFromDisk() now also checks this.sourceHash !== sourceHash before merging entries. If a newer load() has already switched the hash, the stale disk read is discarded entirely.

@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1776038929-session-cache-persistence branch from 5ce7400 to 06d55b6 Compare April 13, 2026 16:51
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

devin-ai-integration Bot and others added 5 commits April 13, 2026 17:38
- SessionCache now accepts optional persistPath for write-through persistence
- Cache validity gated on sourceHash — stale sessions from different compilations are discarded
- In-memory LRU remains the hot path; flush is async and best-effort
- Persistence opt-in via FpfRuntimeOptions.persistSessionCache or FPF_PERSIST_SESSION_CACHE env var
- Adds persistent field to sessionCache summary in RuntimeStatus
- Adds session-cache.json to ARTIFACT_FILENAMES

Closes #9

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
- Skip re-read when sourceHash unchanged (prevents race with flush)
- Clear entries on hash change to discard stale sessions
- Use compact JSON for machine-read cache file

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
…red tuples

- Session cache is an optional runtime file, not a built artifact — use separate
  SESSION_CACHE_FILENAME constant to avoid polluting artifact presence checks
- Persist entries as Array<[string, state]> instead of Record to preserve LRU
  insertion order across JSON round-trips (Object.keys ordering is unreliable
  for integer-like session IDs)

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
- Store in-flight load promise so concurrent callers await the same disk read
  instead of returning early while readFile is still in progress
- Use readFromDisk() helper that merges disk entries without clearing in-memory
  state added by concurrent set() calls (skip keys already present)
- Second caller with same sourceHash awaits loadPromise before returning

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
- After readFile completes, check this.sourceHash still matches the
  expected hash before merging entries
- Prevents a concurrent load() with a different hash from having its
  entries contaminated by an older in-flight disk read

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1776038929-session-cache-persistence branch from 06d55b6 to 9476fd1 Compare April 13, 2026 17:38
@venikman
Copy link
Copy Markdown
Owner Author

Code Review

Overview

Adds opt-in disk persistence to SessionCache so multi-turn context survives process restarts. Gated by FPF_PERSIST_SESSION_CACHE=true env var or persistSessionCache option. File lives at <artifactDir>/session-cache.json.

Strengths

  • Opt-in — default behavior unchanged.
  • Correctly invalidates cache when sourceHash differs: data.sourceHash !== sourceHash → skip load.
  • flushPromise chain serializes writes so disk state stays consistent.
  • summary() exposes persistent: boolean, wired through runtimeStatusSchema and types.

Issues & Risks

Every set() triggers a full-file write

scheduleFlush() serializes the entire entries map to JSON on every set. For a cache with 50 sessions and active multi-turn use, that's 50 writes per session cycle. Consider:

  • Debouncing (e.g. 500ms after last set) to batch writes
  • Or writing a WAL-style append log and compacting periodically

Silent failure on disk write error

.catch(() => {
  // Best-effort persistence — don't crash on write failure
});

Swallowing all errors means a read-only filesystem or full disk silently disables persistence. At minimum, log the first failure (like status() does with refresh errors).

persistPath! non-null assertion

In readFromDisk, the persistPath! assertion is paired with an early return in the caller, which is correct — but brittle. A parameter-passed path would be clearer and avoid the assertion.

load() is racy across concurrent calls

If refresh() is called concurrently (unlikely today, but possible with a multi-request HTTP handler), two load(sourceHash) calls could both start readFromDisk. The loadPromise field is set to the most recent call — the first one's reads could complete after the this.entries.clear() of the second. Low-probability, but guard by awaiting loadPromise before reassigning.

No integrity check

If session-cache.json is corrupted mid-write (crash during flush), the reload just silently starts empty. Consider writing to session-cache.json.tmp then renaming atomically.

Minor

  • summary() now has 4 fields — consider whether persistent: false for the in-memory default is useful signal or noise in get_fpf_index_status.

Recommendation

Approve with follow-up. The feature is good and well-isolated. Atomic writes and at least debouncing the flush cadence before heavy use.

Addresses review feedback:
- Debounced flush (500ms default) batches rapid set() calls
- Atomic write via temp file + rename to prevent corruption
- Log first disk write failure instead of swallowing silently
- Await in-flight loadPromise before reassigning to prevent races
- Pass persistPath as parameter to readFromDisk (no non-null assertion)
- Validate entries array shape before iterating

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

All five issues addressed in commit 342be88:

  1. Debounced flushscheduleFlush() now uses a configurable debounce timer (default 500ms). Rapid set() calls batch into a single write.
  2. Error logging — First disk write failure is logged via console.error('[SessionCache] disk write failed:', err) instead of silently swallowed.
  3. No more persistPath!readFromDisk() now takes path as an explicit parameter.
  4. Concurrent load() guardload() now awaits any in-flight loadPromise before reassigning, preventing the race where two concurrent reads could interleave with entries.clear().
  5. Atomic write — Uses temp file + rename() so a crash mid-write leaves the previous valid file intact. Also validates entries is an array before iterating.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 342be88bbb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +142 to +144
const data: PersistedSessionCache = {
sourceHash: this.sourceHash,
entries: Array.from(this.entries.entries()),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid labeling stale sessions with current source hash

With persistence enabled, concurrent requests can persist old-trace context under a new compilation hash: a query that started before a spec change can call set() after another request has run load(newHash), and doFlush() will stamp all in-memory entries with the current this.sourceHash. That writes stale node IDs as if they belong to newHash, so a restart will reload and reuse invalid context even though load() is supposed to isolate sessions by hash. Consider storing/verifying the originating hash per write (or passing expected hash into set) before flushing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a real race condition. Fixed in d59429c with two guards:

  1. load() cancels pending flush timers — when the sourceHash changes, any scheduled debounce timer is cleared before entries.clear(), so stale entries never get written under the new hash.
  2. doFlush() verifies hash at write time — captures hashAtFlush when the flush starts and checks this.sourceHash !== hashAtFlush before the async write completes, skipping the write if the hash changed in between.

Together these prevent stale session context from being persisted under a newer compilation hash.

…sistence

Co-Authored-By: Stanislau <nedbailov375426@gmail.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

All review feedback addressed across commits. Independent verification:

Key fixes since venikman's review (comment 30):

  • Debounced flush (500ms batching)
  • Error logging on first disk failure
  • persistPath passed as explicit parameter (no more ! assertion)
  • Concurrent load() guard via loadPromise await
  • Atomic write via temp file + rename
  • Stale-context flush prevention (d59429c): cancels pending flush on hash change, verifies hash hasn't changed before writing

PR description updated. Ready to merge.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d59429c627

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +84 to +86
const [key, value] = tuples[i];
if (!this.entries.has(key)) {
this.entries.set(key, value);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate persisted session payloads before restoring

The loader accepts any JSON entries tuple and writes its value directly into entries without checking it matches RetrievalSessionState. With persistence enabled, a parseable-but-invalid cache file (for example from manual edits, format drift, or external corruption) can inject non-array lastSelectedNodeIds/recentUnresolvedNodeIds; downstream query code assumes those fields are arrays and can throw when applying session context, causing requests to fail until the cache file is removed. Treat structurally invalid records as corrupt (skip or clear) instead of restoring them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid hardening suggestion, but not a blocking concern for this PR.

The data is written by our own serialization code (PersistedSessionCacheJSON.stringify), so structural corruption requires external tampering or cross-version format drift. The sourceHash check already gates against stale/incompatible data — if the spec changes, the entire cache is discarded.

The narrow scenario (manually edited cache file with wrong field types) would cause a downstream crash, but the fix is simply deleting the cache file. For a production-grade defense, a Zod schema validation on load would be the right approach, but that's a follow-up improvement — not a merge blocker given the current risk profile.

@venikman
Copy link
Copy Markdown
Owner Author

Correction to my earlier review

Re-checked the current PR state against my concerns — all four are already addressed in commits 342be88b and d59429c6:

  1. Debounced flushflushDelayMs option (default 500ms), clearTimeout + setTimeout coalesces rapid set() calls. ✅
  2. Atomic writes.tmp + rename path. ✅
  3. Error surfacinghasLoggedWriteError flag logs first failure instead of silent swallow. ✅
  4. Race on concurrent load() — "Await any in-flight load before reassigning" guard. ✅

Also caught a bug I didn't think of: canceling the pending flush timer on hash change (d59429c6) so entries from the old sourceHash aren't persisted under the new one. Nice catch.

Revised verdict: merge-ready. Apologies for the stale review — my earlier comment was based on an older snapshot of the diff.

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Thanks for the re-check — glad all four concerns are covered. The flush-timer cancellation on hash change (d59429c6) was a subtle one; good to have it confirmed.

PR remains merge-ready whenever you're ready to land it.

@venikman venikman merged commit 38fb014 into main Apr 14, 2026
2 checks passed
@venikman venikman deleted the devin/1776038929-session-cache-persistence branch May 9, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Persist session cache across process restarts

2 participants