From 2f7942daaa4f72e110954e63533e063e12c0ac7c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:10:42 +0000 Subject: [PATCH 1/7] feat: add file-backed session cache persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/mcp/tool-contracts.ts | 1 + src/runtime/constants.ts | 1 + src/runtime/runtime.ts | 9 ++++- src/runtime/session-cache.ts | 69 ++++++++++++++++++++++++++++++++++-- src/runtime/types.ts | 1 + 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index 3cbe35f..80d954f 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -221,6 +221,7 @@ export const runtimeStatusSchema = z enabled: z.boolean(), maxSessions: z.number(), activeSessions: z.number(), + persistent: z.boolean(), }) .strict(), }) diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts index c1bef21..acdd1c2 100644 --- a/src/runtime/constants.ts +++ b/src/runtime/constants.ts @@ -9,6 +9,7 @@ export const ARTIFACT_FILENAMES = { routeGraph: 'route-graph.json', lexicon: 'lexicon.json', anchorMap: 'anchor-map.json', + sessionCache: 'session-cache.json', } as const; export const PREFACE_MARKER = '# **Preface** (non-normative)'; diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index c426391..821e454 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -34,6 +34,7 @@ export interface FpfRuntimeOptions { artifactDir?: string; synthesizer?: LocalAnswerSynthesizer; maxSessions?: number; + persistSessionCache?: boolean; } export class FpfRuntime { @@ -59,12 +60,18 @@ export class FpfRuntime { ]), ) as Record; this.synthesizer = options.synthesizer ?? createSynthesizerFromEnv(); - this.sessionCache = new SessionCache(options.maxSessions ?? 50); + const persistSession = + options.persistSessionCache ?? process.env.FPF_PERSIST_SESSION_CACHE === 'true'; + this.sessionCache = new SessionCache({ + maxSessions: options.maxSessions ?? 50, + persistPath: persistSession ? this.artifactPaths.sessionCache : undefined, + }); } async refresh(force = false): Promise { await mkdir(this.artifactDir, { recursive: true }); const currentSourceHash = await hashFile(this.sourcePath); + await this.sessionCache.load(currentSourceHash); const existingSnapshot = await this.loadSnapshot(); const compatibleSnapshot = existingSnapshot && !snapshotNeedsRebuild(existingSnapshot); diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index dec87cc..893f987 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -1,3 +1,6 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + export interface RetrievalSessionState { lastNormalizedQuestion: string; lastSelectedNodeIds: string[]; @@ -6,10 +9,50 @@ export interface RetrievalSessionState { updatedAt: string; } +interface PersistedSessionCache { + sourceHash: string; + entries: Record; +} + +export interface SessionCacheOptions { + maxSessions?: number; + persistPath?: string; +} + export class SessionCache { private readonly entries = new Map(); + private readonly maxSessions: number; + private readonly persistPath?: string; + private sourceHash?: string; + private flushPromise?: Promise; + + constructor(options: SessionCacheOptions = {}) { + this.maxSessions = options.maxSessions ?? 50; + this.persistPath = options.persistPath; + } - constructor(private readonly maxSessions = 50) {} + async load(sourceHash: string): Promise { + 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 + } + } get(sessionId: string): RetrievalSessionState | undefined { const value = this.entries.get(sessionId); @@ -33,13 +76,35 @@ export class SessionCache { } this.entries.delete(oldest); } + this.scheduleFlush(); } - summary(): { enabled: boolean; maxSessions: number; activeSessions: number } { + summary(): { enabled: boolean; maxSessions: number; activeSessions: number; persistent: boolean } { return { enabled: true, maxSessions: this.maxSessions, activeSessions: this.entries.size, + persistent: this.persistPath != null, + }; + } + + 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()) + .then(async () => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, json, 'utf8'); + }) + .catch(() => { + // Best-effort persistence — don't crash on write failure + }); } } diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 8b01fcf..2e84a36 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -421,6 +421,7 @@ export interface RuntimeStatus { enabled: boolean; maxSessions: number; activeSessions: number; + persistent: boolean; }; } From d9ee5d88a61302f59c910e79c4433a4570ff59f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:13:34 +0000 Subject: [PATCH 2/7] fix: make load() idempotent and remove JSON pretty-print - 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 --- src/runtime/session-cache.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index 893f987..f4c2796 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -32,7 +32,15 @@ export class SessionCache { } async load(sourceHash: string): Promise { + if (this.sourceHash === sourceHash) { + return; + } + + if (this.sourceHash !== undefined) { + this.entries.clear(); + } this.sourceHash = sourceHash; + if (!this.persistPath) { return; } @@ -97,7 +105,7 @@ export class SessionCache { sourceHash: this.sourceHash, entries: Object.fromEntries(this.entries), }; - const json = JSON.stringify(data, null, 2); + const json = JSON.stringify(data); this.flushPromise = (this.flushPromise ?? Promise.resolve()) .then(async () => { await mkdir(dirname(path), { recursive: true }); From 9c03c4dcceee1cddfba118651a8fa5ba01a06d6b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:16:14 +0000 Subject: [PATCH 3/7] fix: move session cache out of ARTIFACT_FILENAMES and persist as ordered tuples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/runtime/constants.ts | 3 ++- src/runtime/runtime.ts | 5 ++++- src/runtime/session-cache.ts | 14 +++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts index acdd1c2..ef2b210 100644 --- a/src/runtime/constants.ts +++ b/src/runtime/constants.ts @@ -9,9 +9,10 @@ export const ARTIFACT_FILENAMES = { routeGraph: 'route-graph.json', lexicon: 'lexicon.json', anchorMap: 'anchor-map.json', - sessionCache: 'session-cache.json', } as const; +export const SESSION_CACHE_FILENAME = 'session-cache.json'; + export const PREFACE_MARKER = '# **Preface** (non-normative)'; export const PREFACE_ROUTE_CITATION = 'Preface/Where to start'; export const ROUTE_INDEX_CITATION = 'J.4'; diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 821e454..ee191fe 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -6,6 +6,7 @@ import { ARTIFACT_FILENAMES, DEFAULT_ARTIFACT_DIR, DEFAULT_SOURCE_PATH, + SESSION_CACHE_FILENAME, } from './constants.js'; import { compileFpfSource } from './compiler.js'; import { createSynthesizerFromEnv } from './lm-studio-synthesizer.js'; @@ -64,7 +65,9 @@ export class FpfRuntime { options.persistSessionCache ?? process.env.FPF_PERSIST_SESSION_CACHE === 'true'; this.sessionCache = new SessionCache({ maxSessions: options.maxSessions ?? 50, - persistPath: persistSession ? this.artifactPaths.sessionCache : undefined, + persistPath: persistSession + ? resolve(this.artifactDir, SESSION_CACHE_FILENAME) + : undefined, }); } diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index f4c2796..af125eb 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -11,7 +11,7 @@ export interface RetrievalSessionState { interface PersistedSessionCache { sourceHash: string; - entries: Record; + entries: Array<[string, RetrievalSessionState]>; } export interface SessionCacheOptions { @@ -51,11 +51,11 @@ export class SessionCache { 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]); + const tuples = data.entries; + const start = Math.max(0, tuples.length - this.maxSessions); + for (let i = start; i < tuples.length; i++) { + const [key, value] = tuples[i]; + this.entries.set(key, value); } } catch { // File missing or corrupt — start fresh @@ -103,7 +103,7 @@ export class SessionCache { const path = this.persistPath; const data: PersistedSessionCache = { sourceHash: this.sourceHash, - entries: Object.fromEntries(this.entries), + entries: Array.from(this.entries.entries()), }; const json = JSON.stringify(data); this.flushPromise = (this.flushPromise ?? Promise.resolve()) From e76930a3adbcb4d22bbfc66d0576f37427c5e29d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:20:38 +0000 Subject: [PATCH 4/7] fix: prevent race condition in concurrent load() calls - 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 --- src/runtime/session-cache.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index af125eb..6aafa8d 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -25,6 +25,7 @@ export class SessionCache { private readonly persistPath?: string; private sourceHash?: string; private flushPromise?: Promise; + private loadPromise?: Promise; constructor(options: SessionCacheOptions = {}) { this.maxSessions = options.maxSessions ?? 50; @@ -33,6 +34,7 @@ export class SessionCache { async load(sourceHash: string): Promise { if (this.sourceHash === sourceHash) { + await this.loadPromise; return; } @@ -44,18 +46,24 @@ export class SessionCache { if (!this.persistPath) { return; } + this.loadPromise = this.readFromDisk(sourceHash); + await this.loadPromise; + } + + private async readFromDisk(sourceHash: string): Promise { try { - const raw = await readFile(this.persistPath, 'utf8'); + const raw = await readFile(this.persistPath!, 'utf8'); const data: PersistedSessionCache = JSON.parse(raw); if (data.sourceHash !== sourceHash) { return; } - this.entries.clear(); const tuples = data.entries; const start = Math.max(0, tuples.length - this.maxSessions); for (let i = start; i < tuples.length; i++) { const [key, value] = tuples[i]; - this.entries.set(key, value); + if (!this.entries.has(key)) { + this.entries.set(key, value); + } } } catch { // File missing or corrupt — start fresh From 9476fd1f8844f4c777c6f5ad1a3fbea82ad50645 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:26:50 +0000 Subject: [PATCH 5/7] fix: discard stale readFromDisk results after source hash switches - 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 --- src/runtime/session-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index 6aafa8d..b8f3e8f 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -54,7 +54,7 @@ export class SessionCache { try { const raw = await readFile(this.persistPath!, 'utf8'); const data: PersistedSessionCache = JSON.parse(raw); - if (data.sourceHash !== sourceHash) { + if (data.sourceHash !== sourceHash || this.sourceHash !== sourceHash) { return; } const tuples = data.entries; From 342be88bbb67481d81febafb326f807065d8dee2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:36:27 +0000 Subject: [PATCH 6/7] fix: debounce flush, atomic write, log errors, guard concurrent load 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 --- src/runtime/session-cache.ts | 53 ++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index b8f3e8f..37eb346 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -1,5 +1,5 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; export interface RetrievalSessionState { lastNormalizedQuestion: string; @@ -17,19 +17,25 @@ interface PersistedSessionCache { export interface SessionCacheOptions { maxSessions?: number; persistPath?: string; + /** Debounce delay in ms before flushing to disk (default 500). */ + flushDelayMs?: number; } export class SessionCache { private readonly entries = new Map(); private readonly maxSessions: number; private readonly persistPath?: string; + private readonly flushDelayMs: number; private sourceHash?: string; private flushPromise?: Promise; private loadPromise?: Promise; + private flushTimer?: ReturnType; + private hasLoggedWriteError = false; constructor(options: SessionCacheOptions = {}) { this.maxSessions = options.maxSessions ?? 50; this.persistPath = options.persistPath; + this.flushDelayMs = options.flushDelayMs ?? 500; } async load(sourceHash: string): Promise { @@ -38,6 +44,11 @@ export class SessionCache { return; } + // Await any in-flight load before reassigning to avoid race conditions + if (this.loadPromise) { + await this.loadPromise; + } + if (this.sourceHash !== undefined) { this.entries.clear(); } @@ -46,18 +57,22 @@ export class SessionCache { if (!this.persistPath) { return; } - this.loadPromise = this.readFromDisk(sourceHash); + this.loadPromise = this.readFromDisk(this.persistPath, sourceHash); await this.loadPromise; } - private async readFromDisk(sourceHash: string): Promise { + /** Read persisted entries from disk. Path is passed explicitly to avoid non-null assertions. */ + private async readFromDisk(path: string, sourceHash: string): Promise { try { - const raw = await readFile(this.persistPath!, 'utf8'); + const raw = await readFile(path, 'utf8'); const data: PersistedSessionCache = JSON.parse(raw); if (data.sourceHash !== sourceHash || this.sourceHash !== sourceHash) { return; } const tuples = data.entries; + if (!Array.isArray(tuples)) { + return; + } const start = Math.max(0, tuples.length - this.maxSessions); for (let i = start; i < tuples.length; i++) { const [key, value] = tuples[i]; @@ -104,7 +119,22 @@ export class SessionCache { }; } + /** Debounced flush — batches rapid set() calls into a single disk write. */ private scheduleFlush(): void { + if (!this.persistPath || !this.sourceHash) { + return; + } + // Clear any pending debounce timer so we only write once after the last set() + if (this.flushTimer) { + clearTimeout(this.flushTimer); + } + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.doFlush(); + }, this.flushDelayMs); + } + + private doFlush(): void { if (!this.persistPath || !this.sourceHash) { return; } @@ -117,10 +147,17 @@ export class SessionCache { this.flushPromise = (this.flushPromise ?? Promise.resolve()) .then(async () => { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, json, 'utf8'); + // Atomic write: write to temp file then rename to avoid corruption on crash + const tmpPath = join(dirname(path), `.session-cache.tmp.${Date.now()}`); + await writeFile(tmpPath, json, 'utf8'); + await rename(tmpPath, path); }) - .catch(() => { - // Best-effort persistence — don't crash on write failure + .catch((err: unknown) => { + // Log the first write failure so silent disk issues are visible + if (!this.hasLoggedWriteError) { + this.hasLoggedWriteError = true; + console.error('[SessionCache] disk write failed:', err); + } }); } } From d59429c627ee31e3141348cb7a8aa4ea93849d04 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:44:43 +0000 Subject: [PATCH 7/7] fix: cancel pending flush on hash change to prevent stale-context persistence Co-Authored-By: Stanislau --- src/runtime/session-cache.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/runtime/session-cache.ts b/src/runtime/session-cache.ts index 37eb346..732fad9 100644 --- a/src/runtime/session-cache.ts +++ b/src/runtime/session-cache.ts @@ -50,6 +50,12 @@ export class SessionCache { } if (this.sourceHash !== undefined) { + // Cancel any pending flush — entries are about to be cleared so writing them + // under the new sourceHash would persist stale session context. + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } this.entries.clear(); } this.sourceHash = sourceHash; @@ -139,13 +145,21 @@ export class SessionCache { return; } const path = this.persistPath; + // Capture the hash at flush time so we can verify it hasn't changed + // between scheduling and the async write completing. + const hashAtFlush = this.sourceHash; const data: PersistedSessionCache = { - sourceHash: this.sourceHash, + sourceHash: hashAtFlush, entries: Array.from(this.entries.entries()), }; const json = JSON.stringify(data); this.flushPromise = (this.flushPromise ?? Promise.resolve()) .then(async () => { + // If the sourceHash changed while we were waiting, skip this write — + // the entries were captured under a potentially stale hash. + if (this.sourceHash !== hashAtFlush) { + return; + } await mkdir(dirname(path), { recursive: true }); // Atomic write: write to temp file then rename to avoid corruption on crash const tmpPath = join(dirname(path), `.session-cache.tmp.${Date.now()}`);