Skip to content

Commit 2b55f96

Browse files
committed
fix: prevent stale session detail and 60s update lag
DataCache now stores an optional file-state fingerprint (mtimeMs+size) alongside each entry. handleGetSessionDetail stats the JSONL file before the cache lookup and passes the fingerprint to get()/set(); a mismatch invalidates the entry. This is a safety net for FileWatcher events dropped by macOS FSEvents — without it, a missed event left a stale cache entry that survived manual refresh for up to the 10-minute TTL. Also caps the renderer's adaptive session-refresh debounce at 5s (previously up to 60s for sessions with >1000 AI groups), so the UI never appears frozen while a session is actively streaming.
1 parent 9745733 commit 2b55f96

3 files changed

Lines changed: 46 additions & 18 deletions

File tree

src/main/ipc/sessions.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,20 @@ async function handleGetSessionDetail(
217217
const safeSessionId = validatedSession.value!;
218218
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
219219

220-
// Check cache first
221-
let sessionDetail = dataCache.get(cacheKey);
220+
// Stat the JSONL file so we can fingerprint the cache entry.
221+
// Without this, a missed FileWatcher event leaves a stale cache entry
222+
// that survives manual refresh for up to 10 minutes (the TTL).
223+
let fingerprint: string | undefined;
224+
try {
225+
const filePath = projectScanner.getSessionPath(safeProjectId, safeSessionId);
226+
const stats = await projectScanner.getFileSystemProvider().stat(filePath);
227+
fingerprint = `${stats.mtimeMs}-${stats.size}`;
228+
} catch {
229+
// Stat failure is non-fatal — fall through to the existence check below.
230+
}
231+
232+
// Check cache first (returns undefined if fingerprint mismatches)
233+
let sessionDetail = dataCache.get(cacheKey, fingerprint);
222234

223235
if (!sessionDetail) {
224236
const fsType = projectScanner.getFileSystemProvider().type;
@@ -246,8 +258,10 @@ async function handleGetSessionDetail(
246258
// Build session detail with chunks
247259
sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents);
248260

249-
// Cache the result
250-
dataCache.set(cacheKey, sessionDetail);
261+
// Cache the result (paired with the fingerprint we observed pre-parse).
262+
// If the file changed mid-parse, the next get() will see a newer mtime
263+
// and re-fetch — at worst we serve one slightly-stale read.
264+
dataCache.set(cacheKey, sessionDetail, fingerprint);
251265
}
252266

253267
// Strip raw messages before IPC transfer — the renderer never uses them.

src/main/services/infrastructure/DataCache.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ interface CacheEntry<T> {
1818

1919
timestamp: number;
2020
version: number; // Cache schema version
21+
// Optional file-state fingerprint (e.g., `${mtimeMs}-${size}`).
22+
// When present on both set() and get(), a mismatch invalidates the entry —
23+
// a safety net for FileWatcher events dropped by macOS FSEvents.
24+
fingerprint?: string;
2125
}
2226

2327
// Union type for cached values
@@ -64,9 +68,11 @@ export class DataCache {
6468
/**
6569
* Gets a cached session detail.
6670
* @param key - Cache key in format "projectId/sessionId"
67-
* @returns The cached SessionDetail, or undefined if not found or expired
71+
* @param fingerprint - Optional file-state fingerprint. If the cached entry
72+
* has a fingerprint that differs from this one, it's treated as stale.
73+
* @returns The cached SessionDetail, or undefined if not found, expired, or stale
6874
*/
69-
get(key: string): SessionDetail | undefined {
75+
get(key: string, fingerprint?: string): SessionDetail | undefined {
7076
if (!this.enabled) {
7177
return undefined;
7278
}
@@ -91,6 +97,12 @@ export class DataCache {
9197
return undefined;
9298
}
9399

100+
// Check fingerprint mismatch (file changed since cached)
101+
if (fingerprint !== undefined && entry.fingerprint !== fingerprint) {
102+
this.cache.delete(key);
103+
return undefined;
104+
}
105+
94106
// Move to end (mark as recently used)
95107
this.cache.delete(key);
96108
this.cache.set(key, entry);
@@ -141,7 +153,7 @@ export class DataCache {
141153
* Internal method to set a value in the cache.
142154
* Handles LRU eviction and cache entry creation.
143155
*/
144-
private setInternal(key: string, value: CachedValue): void {
156+
private setInternal(key: string, value: CachedValue, fingerprint?: string): void {
145157
if (!this.enabled) {
146158
return;
147159
}
@@ -158,16 +170,19 @@ export class DataCache {
158170
value,
159171
timestamp: Date.now(),
160172
version: DataCache.CURRENT_VERSION,
173+
fingerprint,
161174
});
162175
}
163176

164177
/**
165178
* Sets a value in the cache.
166179
* @param key - Cache key in format "projectId/sessionId"
167180
* @param value - The SessionDetail to cache
181+
* @param fingerprint - Optional file-state fingerprint paired with this entry.
182+
* When provided here AND on a future get(), a mismatch will invalidate.
168183
*/
169-
set(key: string, value: SessionDetail): void {
170-
this.setInternal(key, value);
184+
set(key: string, value: SessionDetail, fingerprint?: string): void {
185+
this.setInternal(key, value, fingerprint);
171186
}
172187

173188
/**

src/renderer/store/index.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export function initializeNotificationListeners(): () => void {
8282
// Adaptive debounce: large sessions refresh less frequently to reduce memory churn.
8383
// Uses the TARGET session's cached totalAIGroups so a long session in another pane
8484
// doesn't force the active short session to the default interval.
85+
// Capped at 5s so the UI never appears frozen for an actively-streaming session.
8586
const state = useStore.getState();
8687
const tabData = Object.values(state.tabSessionData).find(
8788
(td) => td?.sessionDetail?.session?.id === sessionId
@@ -90,15 +91,13 @@ export function initializeNotificationListeners(): () => void {
9091
tabData?.conversation?.totalAIGroups ??
9192
(state.conversation?.items ?? []).filter((i) => i.type === 'ai').length;
9293
const debounceMs =
93-
aiGroupCount > 1000
94-
? 60000 // ~60s for very long sessions (24h+)
95-
: aiGroupCount > 500
96-
? 30000 // ~30s for long sessions
97-
: aiGroupCount > 200
98-
? 10000 // ~10s for medium sessions
99-
: aiGroupCount > 100
100-
? 3000 // ~3s for moderate sessions
101-
: SESSION_REFRESH_DEBOUNCE_MS; // 150ms default
94+
aiGroupCount > 500
95+
? 5000 // 5s ceiling for very long sessions
96+
: aiGroupCount > 200
97+
? 2500 // 2.5s for long sessions
98+
: aiGroupCount > 100
99+
? 1000 // 1s for moderate sessions
100+
: SESSION_REFRESH_DEBOUNCE_MS; // 150ms default
102101

103102
const timer = setTimeout(() => {
104103
pendingSessionRefreshTimers.delete(key);

0 commit comments

Comments
 (0)