From 75e23ecef0c91061c791e79195b2756503d198cd Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Sun, 5 Apr 2026 03:21:20 -0700 Subject: [PATCH] fix: crash prevention with bounded caches and session-scoped invalidation - Add LRU eviction (500 entries) for ProjectScanner caches to prevent unbounded growth over long sessions - Add invalidateCachesForSession() for single-file cache invalidation instead of purging entire project on every file-change event - Add renderer memory monitoring (5-min interval, 2GB warning threshold) to detect leaks before they crash the renderer - Add crash recovery with retry caps, unresponsive watchdog, and crash logging to ~/.claude/claude-devtools-crash.log - Add SubagentMessageCache (LRU, 10 entries, 10min TTL) for lazy-loaded subagent message bodies, distinct from the main DataCache - Add IPC + HTTP handlers for on-demand subagent message loading so the renderer can fetch bodies only when a subagent is expanded - Fix SubagentDetailBuilder path construction to use buildSubagentsPath() Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/http/index.ts | 2 + src/main/http/subagents.ts | 57 ++++- src/main/index.ts | 207 +++++++++++++++++- src/main/ipc/subagents.ts | 85 ++++++- .../analysis/SubagentDetailBuilder.ts | 13 +- src/main/services/discovery/ProjectScanner.ts | 35 +++ .../services/infrastructure/FileWatcher.ts | 8 +- .../services/infrastructure/ServiceContext.ts | 12 +- .../infrastructure/SubagentMessageCache.ts | 127 +++++++++++ src/main/services/infrastructure/index.ts | 1 + src/main/standalone.ts | 1 + src/preload/constants/ipcChannels.ts | 7 + src/preload/index.ts | 5 +- src/renderer/api/httpClient.ts | 10 + src/shared/types/api.ts | 12 +- .../SubagentMessageCache.test.ts | 120 ++++++++++ 16 files changed, 680 insertions(+), 22 deletions(-) create mode 100644 src/main/services/infrastructure/SubagentMessageCache.ts create mode 100644 test/main/services/infrastructure/SubagentMessageCache.test.ts diff --git a/src/main/http/index.ts b/src/main/http/index.ts index e9af8f6f..74d074d3 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -28,6 +28,7 @@ import type { UpdaterService, } from '../services'; import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager'; +import type { SubagentMessageCache } from '../services/infrastructure/SubagentMessageCache'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:routes'); @@ -38,6 +39,7 @@ export interface HttpServices { subagentResolver: SubagentResolver; chunkBuilder: ChunkBuilder; dataCache: DataCache; + subagentMessageCache: SubagentMessageCache; updaterService: UpdaterService; sshConnectionManager: SshConnectionManager; } diff --git a/src/main/http/subagents.ts b/src/main/http/subagents.ts index 8b66c3d4..42f1d463 100644 --- a/src/main/http/subagents.ts +++ b/src/main/http/subagents.ts @@ -2,12 +2,19 @@ * HTTP route handlers for Subagent Operations. * * Routes: - * - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId - Subagent detail + * - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId + * → Subagent detail (drill-down modal payload). + * - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId/messages + * → Lazy-load full message body for inline expansion. Mirrors the IPC + * handler so browser mode works the same as Electron mode. */ import { createLogger } from '@shared/utils/logger'; +import * as path from 'path'; import { validateProjectId, validateSessionId, validateSubagentId } from '../ipc/guards'; +import { SubagentMessageCache } from '../services/infrastructure/SubagentMessageCache'; +import { buildSubagentsPath } from '../utils/pathDecoder'; import type { HttpServices } from './index'; import type { FastifyInstance } from 'fastify'; @@ -74,4 +81,52 @@ export function registerSubagentRoutes(app: FastifyInstance, services: HttpServi } } ); + + // Lazy-load subagent message bodies (mirrors the IPC handler). + app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>( + '/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId/messages', + async (request) => { + try { + const validatedProject = validateProjectId(request.params.projectId); + const validatedSession = validateSessionId(request.params.sessionId); + const validatedSubagent = validateSubagentId(request.params.subagentId); + if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) { + logger.error( + `GET subagent-messages rejected: ${ + validatedProject.error ?? + validatedSession.error ?? + validatedSubagent.error ?? + 'Invalid parameters' + }` + ); + return []; + } + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const safeSubagentId = validatedSubagent.value!; + + const cacheKey = SubagentMessageCache.buildKey( + safeProjectId, + safeSessionId, + safeSubagentId + ); + const cached = services.subagentMessageCache.get(cacheKey); + if (cached) { + return cached; + } + + // Layout: {projectsDir}/{baseProjectId}/{sessionId}/subagents/agent-X.jsonl + const projectsDir = services.projectScanner.getProjectsDir(); + const subagentsDir = buildSubagentsPath(projectsDir, safeProjectId, safeSessionId); + const subagentPath = path.join(subagentsDir, `agent-${safeSubagentId}.jsonl`); + + const parsed = await services.sessionParser.parseSessionFile(subagentPath); + services.subagentMessageCache.set(cacheKey, parsed.messages); + return parsed.messages; + } catch (error) { + logger.error(`Error in GET subagent-messages for ${request.params.subagentId}:`, error); + return []; + } + } + ); } diff --git a/src/main/index.ts b/src/main/index.ts index c50a1c81..8979a77e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,10 +18,31 @@ import { } from '@shared/constants'; import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow, ipcMain } from 'electron'; -import { existsSync } from 'fs'; -import { totalmem } from 'os'; +import { appendFileSync, existsSync, mkdirSync } from 'fs'; +import { homedir, totalmem } from 'os'; import { join } from 'path'; +/** + * Append a timestamped entry to ~/.claude/claude-devtools-crash.log. + * Uses sync I/O because crashes may happen in unstable states. + */ +function writeCrashLog(label: string, details: Record): void { + try { + const dir = join(homedir(), '.claude'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const logPath = join(dir, 'claude-devtools-crash.log'); + const entry = + `[${new Date().toISOString()}] ${label}\n` + + Object.entries(details) + .map(([k, v]) => ` ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`) + .join('\n') + + '\n\n'; + appendFileSync(logPath, entry, 'utf-8'); + } catch { + // Best-effort — don't throw during crash handling + } +} + import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; @@ -60,10 +81,17 @@ const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; process.on('unhandledRejection', (reason) => { logger.error('Unhandled promise rejection in main process:', reason); + writeCrashLog('UNHANDLED_REJECTION (main)', { + reason: reason instanceof Error ? reason.stack ?? reason.message : String(reason), + }); }); -process.on('uncaughtException', (error) => { +process.on('uncaughtException', (error: Error) => { logger.error('Uncaught exception in main process:', error); + writeCrashLog('UNCAUGHT_EXCEPTION (main)', { + message: error.message, + stack: error.stack ?? '', + }); }); import { HttpServer } from './services/infrastructure/HttpServer'; @@ -83,6 +111,7 @@ import { // ============================================================================= let mainWindow: BrowserWindow | null = null; +let isQuitting = false; // Service registry and global services let contextRegistry: ServiceContextRegistry; @@ -366,6 +395,7 @@ async function startHttpServer( subagentResolver: activeContext.subagentResolver, chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, + subagentMessageCache: activeContext.subagentMessageCache, updaterService, sshConnectionManager, }, @@ -546,10 +576,137 @@ function createWindow(): void { } }); - // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) + // Handle renderer process crashes with retry cap to prevent crash loops. + // Only auto-reload for recoverable reasons (crashed, oom, memory-eviction). + // After 3 failures within 60s, stop reloading to avoid infinite loops. + let crashCount = 0; + let crashWindowStart = Date.now(); + const MAX_CRASHES = 3; + const CRASH_WINDOW_MS = 60_000; + const RECOVERABLE_REASONS = new Set(['crashed', 'oom', 'memory-eviction']); + mainWindow.webContents.on('render-process-gone', (_event, details) => { + const memUsage = process.memoryUsage(); logger.error('Renderer process gone:', details.reason, details.exitCode); - // Could show an error dialog or attempt to reload the window + writeCrashLog('RENDERER_PROCESS_GONE', { + reason: details.reason, + exitCode: details.exitCode, + mainProcessRssMB: Math.round(memUsage.rss / 1024 / 1024), + mainProcessHeapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024), + mainProcessHeapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024), + uptime: `${Math.round(process.uptime())}s`, + }); + + if (isQuitting || !mainWindow || mainWindow.isDestroyed()) return; + if (!RECOVERABLE_REASONS.has(details.reason)) return; + + // Reset crash counter if outside window + const now = Date.now(); + if (now - crashWindowStart > CRASH_WINDOW_MS) { + crashCount = 0; + crashWindowStart = now; + } + crashCount++; + + if (crashCount > MAX_CRASHES) { + logger.error( + `Renderer crashed ${crashCount} times in ${CRASH_WINDOW_MS / 1000}s — not reloading` + ); + return; + } + + if (process.env.NODE_ENV === 'development') { + void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`); + } else { + void mainWindow.loadFile(getRendererIndexPath()); + } + }); + + // Log renderer console errors (captures uncaught errors from the renderer process). + // ResizeObserver loop errors are benign Chromium noise — skip them to keep the log clean. + mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { + // level 3 = error + if (level >= 3) { + if (message.includes('ResizeObserver loop')) return; + writeCrashLog('RENDERER_CONSOLE_ERROR', { + message, + source: `${sourceId}:${line}`, + }); + } + }); + + // Proactive unresponsive recovery. + // When the renderer freezes, the Linux desktop environment (GNOME/KDE) may show its + // own "Force Quit" dialog and kill the entire process tree. We race that by + // force-reloading the renderer after UNRESPONSIVE_RELOAD_MS. If the renderer + // becomes responsive again before the timer fires, we cancel the reload. + // Capped at MAX_UNRESPONSIVE_RELOADS within UNRESPONSIVE_WINDOW_MS to prevent + // infinite reload loops when a large session freezes the renderer on every load. + const UNRESPONSIVE_RELOAD_MS = 10_000; + const MAX_UNRESPONSIVE_RELOADS = 3; + const UNRESPONSIVE_WINDOW_MS = 120_000; // 2 minutes + let unresponsiveTimer: ReturnType | null = null; + let unresponsiveReloadCount = 0; + let unresponsiveWindowStart = Date.now(); + + mainWindow.on('unresponsive', () => { + const memUsage = process.memoryUsage(); + logger.error('Renderer became unresponsive'); + writeCrashLog('RENDERER_UNRESPONSIVE', { + note: 'Window stopped responding — will force-reload in 10s unless it recovers', + mainProcessRssMB: Math.round(memUsage.rss / 1024 / 1024), + mainProcessHeapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024), + mainProcessHeapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024), + uptime: `${Math.round(process.uptime())}s`, + }); + + // Don't stack multiple timers + if (unresponsiveTimer) return; + + unresponsiveTimer = setTimeout(() => { + unresponsiveTimer = null; + if (isQuitting || !mainWindow || mainWindow.isDestroyed()) return; + + // Reset counter if outside the window + const now = Date.now(); + if (now - unresponsiveWindowStart > UNRESPONSIVE_WINDOW_MS) { + unresponsiveReloadCount = 0; + unresponsiveWindowStart = now; + } + unresponsiveReloadCount++; + + if (unresponsiveReloadCount > MAX_UNRESPONSIVE_RELOADS) { + logger.error( + `Renderer unresponsive ${unresponsiveReloadCount} times in ${UNRESPONSIVE_WINDOW_MS / 1000}s — not reloading` + ); + writeCrashLog('RENDERER_RELOAD_CAP_REACHED', { + reason: `${unresponsiveReloadCount} unresponsive reloads in ${UNRESPONSIVE_WINDOW_MS / 1000}s`, + uptime: `${Math.round(process.uptime())}s`, + }); + return; + } + + logger.error('Renderer still unresponsive after 10s — force-reloading'); + writeCrashLog('RENDERER_FORCE_RELOAD', { + reason: 'Unresponsive timeout expired', + attempt: unresponsiveReloadCount, + uptime: `${Math.round(process.uptime())}s`, + }); + + if (process.env.NODE_ENV === 'development') { + void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`); + } else { + void mainWindow.loadFile(getRendererIndexPath()); + } + }, UNRESPONSIVE_RELOAD_MS); + }); + + mainWindow.on('responsive', () => { + if (unresponsiveTimer) { + clearTimeout(unresponsiveTimer); + unresponsiveTimer = null; + logger.info('Renderer became responsive again — cancelled force-reload'); + } }); // Set main window reference for notification manager and updater @@ -560,6 +717,43 @@ function createWindow(): void { updaterService.setMainWindow(mainWindow); } + // Periodic memory monitoring via app.getAppMetrics(). + // Logs all-process memory every 5 minutes so we have data leading up to crashes. + // Warns when the renderer exceeds 2 GB. + const MEMORY_CHECK_INTERVAL_MS = 5 * 60_000; + const RENDERER_MEMORY_WARNING_KB = 2048 * 1024; // 2 GB in KB + const memoryMonitorInterval = setInterval(() => { + if (!mainWindow || mainWindow.isDestroyed()) return; + try { + const metrics = app.getAppMetrics(); + const mainMem = process.memoryUsage(); + const mainRssMB = Math.round(mainMem.rss / 1024 / 1024); + const mainHeapMB = Math.round(mainMem.heapUsed / 1024 / 1024); + + // Find the renderer process (type 'Tab' or matching the window's pid) + const rendererPid = mainWindow.webContents.getOSProcessId(); + const rendererMetric = metrics.find((m) => m.pid === rendererPid); + const rendererMemKB = rendererMetric?.memory?.workingSetSize ?? 0; + const rendererMB = Math.round(rendererMemKB / 1024); + + logger.info( + `Memory: renderer=${rendererMB}MB, main RSS=${mainRssMB}MB heap=${mainHeapMB}MB, uptime=${Math.round(process.uptime())}s` + ); + + if (rendererMemKB > RENDERER_MEMORY_WARNING_KB) { + writeCrashLog('RENDERER_MEMORY_WARNING', { + rendererMB, + mainRssMB, + mainHeapMB, + uptime: `${Math.round(process.uptime())}s`, + }); + } + } catch { + // Renderer might be crashed/reloading — skip this check + } + }, MEMORY_CHECK_INTERVAL_MS); + memoryMonitorInterval.unref(); // Don't prevent app exit + logger.info('Main window created'); } @@ -626,8 +820,9 @@ app.on('window-all-closed', () => { }); /** - * Before quit handler - cleanup. + * Before quit handler - set flag and cleanup services. */ app.on('before-quit', () => { + isQuitting = true; shutdownServices(); }); diff --git a/src/main/ipc/subagents.ts b/src/main/ipc/subagents.ts index 747605d3..bfc486a1 100644 --- a/src/main/ipc/subagents.ts +++ b/src/main/ipc/subagents.ts @@ -3,12 +3,19 @@ * * Handlers: * - get-subagent-detail: Get detailed information for a specific subagent + * (used by the drill-down modal — returns parsed chunks). + * - subagent:get-messages: Lazy-load a subagent's full message body for + * inline expansion in SubagentItem. Backed by SubagentMessageCache so + * repeat expansions are instant. */ +import { SubagentMessageCache } from '@main/services/infrastructure/SubagentMessageCache'; +import { buildSubagentsPath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; +import * as path from 'path'; -import { type SubagentDetail } from '../types'; +import { type ParsedMessage, type SubagentDetail } from '../types'; import { validateProjectId, validateSessionId, validateSubagentId } from './guards'; @@ -16,6 +23,14 @@ import type { ServiceContextRegistry } from '../services'; const logger = createLogger('IPC:subagents'); +/** + * IPC channel for lazy-loading subagent message bodies. + * Mirrors `SUBAGENT_GET_MESSAGES` in `preload/constants/ipcChannels.ts` — + * kept as a literal here because main can't import from preload (boundary + * rule). Update both sides if this string changes. + */ +const CHANNEL_GET_SUBAGENT_MESSAGES = 'subagent:get-messages'; + // Service registry - set via initialize let registry: ServiceContextRegistry; @@ -31,6 +46,7 @@ export function initializeSubagentHandlers(contextRegistry: ServiceContextRegist */ export function registerSubagentHandlers(ipcMain: IpcMain): void { ipcMain.handle('get-subagent-detail', handleGetSubagentDetail); + ipcMain.handle(CHANNEL_GET_SUBAGENT_MESSAGES, handleGetSubagentMessages); logger.info('Subagent handlers registered'); } @@ -40,6 +56,7 @@ export function registerSubagentHandlers(ipcMain: IpcMain): void { */ export function removeSubagentHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('get-subagent-detail'); + ipcMain.removeHandler(CHANNEL_GET_SUBAGENT_MESSAGES); logger.info('Subagent handlers removed'); } @@ -120,3 +137,69 @@ async function handleGetSubagentDetail( return null; } } + +/** + * Handler for 'subagent:get-messages' IPC call. + * Lazy-loads a single subagent's parsed messages on demand. Used by the + * inline SubagentItem when a user expands an entry — the worker output now + * carries `messages: []` to keep cached SessionDetails small, so the body + * has to be fetched here. + * + * Cached in SubagentMessageCache (small LRU, distinct from DataCache so + * subagent bodies don't compete with full SessionDetails for slots). + */ +async function handleGetSubagentMessages( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string, + subagentId: string +): Promise { + try { + const validatedProject = validateProjectId(projectId); + const validatedSession = validateSessionId(sessionId); + const validatedSubagent = validateSubagentId(subagentId); + if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) { + logger.error( + `subagent:get-messages rejected: ${ + validatedProject.error ?? + validatedSession.error ?? + validatedSubagent.error ?? + 'Invalid parameters' + }` + ); + return []; + } + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const safeSubagentId = validatedSubagent.value!; + + const { sessionParser, projectScanner, subagentMessageCache } = registry.getActive(); + + const cacheKey = SubagentMessageCache.buildKey(safeProjectId, safeSessionId, safeSubagentId); + const cached = subagentMessageCache.get(cacheKey); + if (cached) { + logger.debug( + `subagent:get-messages cache hit ${safeSubagentId} (${cached.length} msgs)` + ); + return cached; + } + + // Construct the subagent file path. The actual on-disk layout is: + // {projectsDir}/{baseProjectId}/{sessionId}/subagents/agent-{subagentId}.jsonl + // `buildSubagentsPath` handles the composite-id split and gives us the + // {.../subagents} directory; we append the agent file ourselves. + const projectsDir = projectScanner.getProjectsDir(); + const subagentsDir = buildSubagentsPath(projectsDir, safeProjectId, safeSessionId); + const subagentPath = path.join(subagentsDir, `agent-${safeSubagentId}.jsonl`); + + const parsed = await sessionParser.parseSessionFile(subagentPath); + logger.info( + `subagent:get-messages loaded ${safeSubagentId} from ${subagentPath} (${parsed.messages.length} messages)` + ); + subagentMessageCache.set(cacheKey, parsed.messages); + return parsed.messages; + } catch (error) { + logger.error(`Error in subagent:get-messages for ${subagentId}:`, error); + return []; + } +} diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index 8fde9184..95b28dfb 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -14,6 +14,7 @@ import { type SemanticStepGroup, type SubagentDetail, } from '@main/types'; +import { buildSubagentsPath } from '@main/utils/pathDecoder'; import { countTokens } from '@main/utils/tokenizer'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; @@ -42,7 +43,7 @@ import type { SessionParser } from '../parsing/SessionParser'; */ export async function buildSubagentDetail( projectId: string, - _sessionId: string, // Unused but kept for API consistency + sessionId: string, subagentId: string, sessionParser: SessionParser, subagentResolver: SubagentResolver, @@ -51,13 +52,9 @@ export async function buildSubagentDetail( projectsDir: string ): Promise { try { - // Construct path to subagent JSONL file - const subagentPath = path.join( - projectsDir, - projectId, - 'subagents', - `agent-${subagentId}.jsonl` - ); + // Layout: {projectsDir}/{baseProjectId}/{sessionId}/subagents/agent-X.jsonl + const subagentsDir = buildSubagentsPath(projectsDir, projectId, sessionId); + const subagentPath = path.join(subagentsDir, `agent-${subagentId}.jsonl`); // Check if file exists if (!(await fsProvider.exists(subagentPath))) { diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 069af1bc..5ee62335 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -60,6 +60,9 @@ const logger = createLogger('Discovery:ProjectScanner'); const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; export class ProjectScanner { + /** Maximum number of entries per cache before LRU eviction kicks in. */ + private static readonly MAX_CACHE_ENTRIES = 500; + private readonly projectsDir: string; private readonly todosDir: string; private readonly contentPresenceCache = new Map< @@ -99,6 +102,35 @@ export class ProjectScanner { this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); } + // =========================================================================== + // Cache Management + // =========================================================================== + + /** + * Evicts the oldest entries from a Map when it exceeds MAX_CACHE_ENTRIES. + * Relies on Map insertion order (oldest entries are iterated first). + */ + private pruneCache(cache: Map): void { + if (cache.size <= ProjectScanner.MAX_CACHE_ENTRIES) return; + const excess = cache.size - ProjectScanner.MAX_CACHE_ENTRIES; + let removed = 0; + for (const key of cache.keys()) { + if (removed >= excess) break; + cache.delete(key); + removed++; + } + } + + /** + * Invalidates all scanner caches for a single session file path. + * Called by FileWatcher when a session file changes, so only the changed + * session is evicted instead of the entire project. + */ + invalidateCachesForSession(sessionFilePath: string): void { + this.contentPresenceCache.delete(sessionFilePath); + this.sessionMetadataCache.delete(sessionFilePath); + } + // =========================================================================== // Project Scanning // =========================================================================== @@ -734,6 +766,7 @@ export class ProjectScanner { size: effectiveSize, metadata, }); + this.pruneCache(this.sessionMetadataCache); } // Check for subagents and load task list data in parallel @@ -806,6 +839,7 @@ export class ProjectScanner { size: effectiveSize, metadata, }); + this.pruneCache(this.sessionMetadataCache); } catch (error) { logger.debug(`Failed to analyze session metadata for ${filePath}:`, error); metadata = { @@ -1428,6 +1462,7 @@ export class ProjectScanner { size: effectiveSize, hasContent, }); + this.pruneCache(this.contentPresenceCache); return hasContent; } catch { return false; diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 45134ad4..fd69cc6a 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -111,7 +111,9 @@ export class FileWatcher extends EventEmitter { } /** - * Sets the ProjectScanner for cache invalidation on file changes. + * Sets the ProjectScanner for session-scoped cache invalidation. + * When set, file change events invalidate only the changed session file's + * cached metadata/presence/preview instead of nothing at all. */ setProjectScanner(scanner: ProjectScanner): void { this.projectScanner = scanner; @@ -545,9 +547,9 @@ export class FileWatcher extends EventEmitter { } if (sessionId) { - // Invalidate cache + // Invalidate caches — session-scoped for ProjectScanner, keyed for DataCache this.dataCache.invalidateSession(projectId, sessionId); - this.projectScanner?.invalidateCachesForProject(projectId); + this.projectScanner?.invalidateCachesForSession(fullPath); projectPathResolver.invalidateProject(projectId); if (changeType === 'unlink') { this.clearErrorTracking(fullPath); diff --git a/src/main/services/infrastructure/ServiceContext.ts b/src/main/services/infrastructure/ServiceContext.ts index 03d5e82e..44b96e7e 100644 --- a/src/main/services/infrastructure/ServiceContext.ts +++ b/src/main/services/infrastructure/ServiceContext.ts @@ -24,6 +24,7 @@ import { createLogger } from '@shared/utils/logger'; import { DataCache } from './DataCache'; import { FileWatcher } from './FileWatcher'; +import { SubagentMessageCache } from './SubagentMessageCache'; import type { FileSystemProvider } from './FileSystemProvider'; @@ -73,6 +74,7 @@ export class ServiceContext { readonly subagentResolver: SubagentResolver; readonly chunkBuilder: ChunkBuilder; readonly dataCache: DataCache; + readonly subagentMessageCache: SubagentMessageCache; readonly fileWatcher: FileWatcher; private cleanupInterval: NodeJS.Timeout | null = null; @@ -107,7 +109,12 @@ export class ServiceContext { // 5. DataCache - standalone service this.dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, !disableCache); - // 6. FileWatcher - uses fsProvider and dataCache + // 6. SubagentMessageCache - separate LRU for lazy-loaded subagent bodies. + // Sized small (10) because each entry holds a full transcript and only + // actively-expanded subagents need to be retained. + this.subagentMessageCache = new SubagentMessageCache(10, CACHE_TTL_MINUTES, !disableCache); + + // 7. FileWatcher - uses fsProvider and dataCache this.fileWatcher = new FileWatcher( this.dataCache, config.projectsDir, @@ -178,6 +185,9 @@ export class ServiceContext { // Dispose DataCache this.dataCache.dispose(); + // Dispose SubagentMessageCache + this.subagentMessageCache.dispose(); + // Clear cleanup interval if (this.cleanupInterval) { clearInterval(this.cleanupInterval); diff --git a/src/main/services/infrastructure/SubagentMessageCache.ts b/src/main/services/infrastructure/SubagentMessageCache.ts new file mode 100644 index 00000000..c820a2b9 --- /dev/null +++ b/src/main/services/infrastructure/SubagentMessageCache.ts @@ -0,0 +1,127 @@ +/** + * SubagentMessageCache - LRU cache for lazy-loaded subagent message bodies. + * + * Distinct from DataCache so that subagent message arrays don't compete with + * full SessionDetails for slots. Sized small (default 10) because each entry + * holds a full subagent transcript and we only need to retain the few that + * the user is actively expanding. Entries expire after `ttlMinutes` minutes. + * + * Key format: `${projectId}::${sessionId}::${subagentId}`. + */ + +import { type ParsedMessage } from '@main/types'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Service:SubagentMessageCache'); + +interface CacheEntry { + messages: ParsedMessage[]; + timestamp: number; +} + +export class SubagentMessageCache { + private cache = new Map(); + private readonly maxSize: number; + private readonly ttlMs: number; + private enabled: boolean; + private disposed = false; + + constructor(maxSize: number = 10, ttlMinutes: number = 10, enabled: boolean = true) { + this.maxSize = maxSize; + this.ttlMs = ttlMinutes * 60 * 1000; + this.enabled = enabled; + } + + /** Build the canonical cache key. */ + static buildKey(projectId: string, sessionId: string, subagentId: string): string { + return `${projectId}::${sessionId}::${subagentId}`; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled) this.cache.clear(); + } + + isEnabled(): boolean { + return this.enabled; + } + + /** + * Read a cache entry. Returns undefined if missing, expired, or disabled. + * Touches the entry on read so LRU eviction reflects recency. + */ + get(key: string): ParsedMessage[] | undefined { + if (!this.enabled) return undefined; + const entry = this.cache.get(key); + if (!entry) return undefined; + + if (Date.now() - entry.timestamp > this.ttlMs) { + this.cache.delete(key); + return undefined; + } + + // Refresh LRU position by re-inserting at the back. + this.cache.delete(key); + this.cache.set(key, entry); + return entry.messages; + } + + /** + * Store a value, evicting the least-recently-used entry if at capacity. + * Re-setting an existing key refreshes its LRU position. + */ + set(key: string, messages: ParsedMessage[]): void { + if (!this.enabled) return; + + // Refresh LRU order if the key already exists. + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.maxSize) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(key, { messages, timestamp: Date.now() }); + } + + /** + * Drop every entry whose key matches the given session. + * Called when a session refresh invalidates downstream subagent state. + */ + invalidateSession(projectId: string, sessionId: string): void { + const prefix = `${projectId}::${sessionId}::`; + for (const key of [...this.cache.keys()]) { + if (key.startsWith(prefix)) { + this.cache.delete(key); + } + } + } + + /** Drop every entry for a given project (any session). */ + invalidateProject(projectId: string): void { + const prefix = `${projectId}::`; + for (const key of [...this.cache.keys()]) { + if (key.startsWith(prefix)) { + this.cache.delete(key); + } + } + } + + size(): number { + return this.cache.size; + } + + clear(): void { + this.cache.clear(); + } + + dispose(): void { + if (this.disposed) return; + logger.info('Disposing SubagentMessageCache'); + this.cache.clear(); + this.enabled = false; + this.disposed = true; + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 52a8840b..00f02ddd 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -28,5 +28,6 @@ export * from './ServiceContextRegistry'; export * from './SshConfigParser'; export * from './SshConnectionManager'; export * from './SshFileSystemProvider'; +export * from './SubagentMessageCache'; export * from './TriggerManager'; export * from './UpdaterService'; diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 1d7278dc..a6859ef0 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -153,6 +153,7 @@ async function start(): Promise { subagentResolver: localContext.subagentResolver, chunkBuilder: localContext.chunkBuilder, dataCache: localContext.dataCache, + subagentMessageCache: localContext.subagentMessageCache, updaterService: updaterServiceStub, sshConnectionManager: sshConnectionManagerStub, }; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 626477ae..31c9be76 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -187,3 +187,10 @@ export const FIND_SESSION_BY_ID = 'find-session-by-id'; /** Find sessions whose IDs contain a given hex fragment */ export const FIND_SESSIONS_BY_PARTIAL_ID = 'find-sessions-by-partial-id'; + +// ============================================================================= +// Subagent API Channels +// ============================================================================= + +/** Lazy-load a single subagent's parsed messages (renderer expansion path) */ +export const SUBAGENT_GET_MESSAGES = 'subagent:get-messages'; diff --git a/src/preload/index.ts b/src/preload/index.ts index b8850cc1..8d13a5bf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,7 @@ import { SSH_SAVE_LAST_CONNECTION, SSH_STATUS, SSH_TEST, + SUBAGENT_GET_MESSAGES, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -96,6 +97,7 @@ interface IpcFileChangePayload { projectId?: string; sessionId?: string; isSubagent: boolean; + fileSize?: number; } /** @@ -152,11 +154,12 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke('get-waterfall-data', projectId, sessionId), getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), + getSubagentMessages: (projectId: string, sessionId: string, subagentId: string) => + ipcRenderer.invoke(SUBAGENT_GET_MESSAGES, projectId, sessionId, subagentId), getSessionGroups: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-groups', projectId, sessionId), getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) => ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options), - // Repository grouping (worktree support) getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'), getWorktreeSessions: (worktreeId: string) => diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5a29f29e..02d620f0 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -23,6 +23,7 @@ import type { NotificationsAPI, NotificationTrigger, PaginatedSessionsResult, + ParsedMessage, Project, RepositoryGroup, SearchSessionsResult, @@ -255,6 +256,15 @@ export class HttpAPIClient implements ElectronAPI { `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` ); + getSubagentMessages = ( + projectId: string, + sessionId: string, + subagentId: string + ): Promise => + this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}/messages` + ); + getSessionGroups = (projectId: string, sessionId: string): Promise => this.get( `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups` diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ca2c648c..0d956c4d 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -20,6 +20,7 @@ import type { FindSessionByIdResult, FindSessionsByPartialIdResult, PaginatedSessionsResult, + ParsedMessage, Project, RepositoryGroup, SearchSessionsResult, @@ -349,13 +350,22 @@ export interface ElectronAPI { sessionId: string, subagentId: string ) => Promise; + /** + * Lazy-load a subagent's full parsed message body. Worker output now strips + * `Process.messages` to bound memory; renderer calls this when a subagent + * is expanded inline. Backed by an LRU cache in the main process. + */ + getSubagentMessages: ( + projectId: string, + sessionId: string, + subagentId: string + ) => Promise; getSessionGroups: (projectId: string, sessionId: string) => Promise; getSessionsByIds: ( projectId: string, sessionIds: string[], options?: SessionsByIdsOptions ) => Promise; - // Repository grouping (worktree support) getRepositoryGroups: () => Promise; getWorktreeSessions: (worktreeId: string) => Promise; diff --git a/test/main/services/infrastructure/SubagentMessageCache.test.ts b/test/main/services/infrastructure/SubagentMessageCache.test.ts new file mode 100644 index 00000000..ef49c219 --- /dev/null +++ b/test/main/services/infrastructure/SubagentMessageCache.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for SubagentMessageCache. + * + * Covers: + * - Basic set/get round trip + * - LRU eviction at maxSize + * - Re-set updates LRU order + * - TTL expiry + * - invalidateSession drops only matching entries + * - dispose clears and disables + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SubagentMessageCache } from '../../../../src/main/services/infrastructure/SubagentMessageCache'; +import type { ParsedMessage } from '../../../../src/main/types'; + +function fakeMessages(n: number): ParsedMessage[] { + return Array.from({ length: n }, (_, i) => ({ + uuid: `m-${i}`, + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date(), + content: '', + isSidechain: true, + isMeta: false, + toolCalls: [], + toolResults: [], + })); +} + +describe('SubagentMessageCache', () => { + let cache: SubagentMessageCache; + + beforeEach(() => { + cache = new SubagentMessageCache(3, 10); // 3 entries, 10 min TTL + }); + + it('round-trips messages by key', () => { + const key = SubagentMessageCache.buildKey('p1', 's1', 'a1'); + const msgs = fakeMessages(5); + cache.set(key, msgs); + expect(cache.get(key)).toBe(msgs); + expect(cache.size()).toBe(1); + }); + + it('returns undefined for missing keys', () => { + expect(cache.get('nope')).toBeUndefined(); + }); + + it('evicts least-recently-used when at capacity', () => { + cache.set('p::s::a1', fakeMessages(1)); + cache.set('p::s::a2', fakeMessages(1)); + cache.set('p::s::a3', fakeMessages(1)); + // Touching a1 makes a2 the oldest. + cache.get('p::s::a1'); + cache.set('p::s::a4', fakeMessages(1)); + expect(cache.get('p::s::a2')).toBeUndefined(); + expect(cache.get('p::s::a1')).toBeDefined(); + expect(cache.get('p::s::a3')).toBeDefined(); + expect(cache.get('p::s::a4')).toBeDefined(); + }); + + it('re-setting an existing key refreshes LRU order without growing size', () => { + cache.set('p::s::a1', fakeMessages(1)); + cache.set('p::s::a2', fakeMessages(1)); + cache.set('p::s::a3', fakeMessages(1)); + cache.set('p::s::a1', fakeMessages(2)); // refresh a1 + cache.set('p::s::a4', fakeMessages(1)); + // a2 should be evicted (oldest after a1 was refreshed). + expect(cache.get('p::s::a2')).toBeUndefined(); + expect(cache.get('p::s::a1')).toHaveLength(2); + }); + + it('expires entries past their TTL', () => { + vi.useFakeTimers(); + const shortLived = new SubagentMessageCache(3, 1); // 1 minute TTL + shortLived.set('k', fakeMessages(1)); + expect(shortLived.get('k')).toBeDefined(); + vi.advanceTimersByTime(2 * 60 * 1000); + expect(shortLived.get('k')).toBeUndefined(); + vi.useRealTimers(); + }); + + it('invalidateSession drops only matching entries', () => { + cache.set(SubagentMessageCache.buildKey('p1', 's1', 'a1'), fakeMessages(1)); + cache.set(SubagentMessageCache.buildKey('p1', 's2', 'a2'), fakeMessages(1)); + cache.set(SubagentMessageCache.buildKey('p2', 's1', 'a3'), fakeMessages(1)); + cache.invalidateSession('p1', 's1'); + expect(cache.get(SubagentMessageCache.buildKey('p1', 's1', 'a1'))).toBeUndefined(); + expect(cache.get(SubagentMessageCache.buildKey('p1', 's2', 'a2'))).toBeDefined(); + expect(cache.get(SubagentMessageCache.buildKey('p2', 's1', 'a3'))).toBeDefined(); + }); + + it('invalidateProject drops every entry for a project', () => { + cache.set(SubagentMessageCache.buildKey('p1', 's1', 'a1'), fakeMessages(1)); + cache.set(SubagentMessageCache.buildKey('p1', 's2', 'a2'), fakeMessages(1)); + cache.set(SubagentMessageCache.buildKey('p2', 's1', 'a3'), fakeMessages(1)); + cache.invalidateProject('p1'); + expect(cache.size()).toBe(1); + expect(cache.get(SubagentMessageCache.buildKey('p2', 's1', 'a3'))).toBeDefined(); + }); + + it('respects setEnabled(false) — get/set become no-ops and clears entries', () => { + cache.set('k', fakeMessages(1)); + cache.setEnabled(false); + expect(cache.get('k')).toBeUndefined(); + expect(cache.isEnabled()).toBe(false); + cache.set('k2', fakeMessages(1)); + expect(cache.size()).toBe(0); + }); + + it('dispose clears, disables, and is idempotent', () => { + cache.set('k', fakeMessages(1)); + cache.dispose(); + expect(cache.size()).toBe(0); + expect(cache.isEnabled()).toBe(false); + expect(() => cache.dispose()).not.toThrow(); + }); +});