diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..40018d2057 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -287,6 +287,11 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { initializePackagedLogging(); +// Cap V8 heap to 2 GB so GC runs more aggressively instead of +// growing to 3.7 GB+ and crashing with OOM. This applies to both +// the main process and all renderer/utility child processes. +app.commandLine.appendSwitch("js-flags", "--max-old-space-size=2048"); + if (process.platform === "linux") { app.commandLine.appendSwitch("class", LINUX_WM_CLASS); } @@ -1354,6 +1359,10 @@ function createWindow(): BrowserWindow { contextIsolation: true, nodeIntegration: false, sandbox: true, + // Ensure the renderer's V8 heap is capped at 2 GB to prevent OOM crashes. + // app.commandLine.appendSwitch("js-flags", ...) alone doesn't propagate + // to sandboxed renderer processes reliably. + additionalArguments: ["--js-flags=--max-old-space-size=2048"], }, }); @@ -1393,6 +1402,26 @@ function createWindow(): BrowserWindow { return { action: "deny" }; }); + window.webContents.on("render-process-gone", (_event, details) => { + writeDesktopLogHeader(`renderer crashed reason=${details.reason} exitCode=${details.exitCode}`); + if (details.reason === "crashed" || details.reason === "oom" || details.reason === "killed") { + setTimeout(() => { + if (!window.isDestroyed()) { + writeDesktopLogHeader("reloading renderer after crash"); + window.webContents.reload(); + } + }, 500); + } + }); + + window.webContents.on("unresponsive", () => { + writeDesktopLogHeader("renderer became unresponsive"); + }); + + window.webContents.on("responsive", () => { + writeDesktopLogHeader("renderer became responsive again"); + }); + window.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(APP_DISPLAY_NAME); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 911a601955..392ccc282f 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -54,6 +54,33 @@ const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +/** + * Negative cache for worktree paths that are known to be missing. + * Prevents repeated stat() calls on deleted worktree directories, + * which can create a tight ENOENT error loop with growing fiber IDs. + * Entries expire after 5 minutes so that re-created paths are picked up. + */ +const MISSING_WORKTREE_CACHE_TTL_MS = 5 * 60 * 1000; +const missingWorktreePaths = new Map(); + +function isWorktreePathKnownMissing(path: string): boolean { + const cachedAt = missingWorktreePaths.get(path); + if (cachedAt === undefined) return false; + if (Date.now() - cachedAt > MISSING_WORKTREE_CACHE_TTL_MS) { + missingWorktreePaths.delete(path); + return false; + } + return true; +} + +function markWorktreePathMissing(path: string): void { + missingWorktreePaths.set(path, Date.now()); +} + +function markWorktreePathPresent(path: string): void { + missingWorktreePaths.delete(path); +} + type TraceTailState = { processedChars: number; remainder: string; @@ -1805,10 +1832,21 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { for (const line of worktreeList.stdout.split("\n")) { if (line.startsWith("worktree ")) { const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); + let exists: boolean; + if (isWorktreePathKnownMissing(candidatePath)) { + exists = false; + } else { + exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => { + markWorktreePathPresent(candidatePath); + return true; + }), + Effect.catch(() => { + markWorktreePathMissing(candidatePath); + return Effect.succeed(false); + }), + ); + } currentPath = exists ? candidatePath : null; } else if (line.startsWith("branch refs/heads/") && currentPath) { worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..a9c3fcb4c8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -203,6 +203,7 @@ const makeThread = (input?: { worktreePath: null, turnDiffSummaries: [], activities: [], + hydrated: true, }); afterEach(() => { @@ -356,6 +357,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { worktreePath: null, turnDiffSummaries: [], activities: [], + hydrated: true, }); expect( @@ -392,6 +394,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { worktreePath: null, turnDiffSummaries: [], activities: [], + hydrated: true, }); expect( @@ -437,6 +440,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { worktreePath: null, turnDiffSummaries: [], activities: [], + hydrated: true, }); expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..dbb423a884 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -41,6 +41,7 @@ export function buildLocalDraftThread( turnDiffSummaries: [], activities: [], proposedPlans: [], + hydrated: true, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c649b5003..3ce2613b18 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -578,6 +578,7 @@ function PersistentThreadTerminalDrawer({ export default function ChatView({ threadId }: ChatViewProps) { const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); + const hydrateThreadAction = useStore((store) => store.hydrateThread); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[threadId], @@ -837,6 +838,26 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; + + // Re-hydrate thread data if this thread was evicted from memory + useEffect(() => { + if (serverThread && !serverThread.hydrated) { + const api = readNativeApi(); + if (!api) return; + api.orchestration + .getSnapshot() + .then((snapshot) => { + const fullThread = snapshot.threads.find((t) => t.id === serverThread.id); + if (fullThread) { + hydrateThreadAction(serverThread.id, fullThread); + } + }) + .catch((err) => { + console.error("Failed to hydrate thread", serverThread.id, err); + }); + } + }, [serverThread?.id, serverThread?.hydrated, hydrateThreadAction]); + const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = @@ -3898,6 +3919,15 @@ export default function ChatView({ threadId }: ChatViewProps) { void onRevertToTurnCount(targetTurnCount); }; + // Show loading state for dehydrated threads + if (activeThread && !activeThread.hydrated) { + return ( +
+ Loading conversation... +
+ ); + } + // Empty state: no active thread if (!activeThread) { return ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..7dfb3cda3e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -664,6 +664,7 @@ function makeThread(overrides: Partial = {}): Thread { worktreePath: null, turnDiffSummaries: [], activities: [], + hydrated: true, ...overrides, }; } diff --git a/apps/web/src/lib/threadEviction.test.ts b/apps/web/src/lib/threadEviction.test.ts new file mode 100644 index 0000000000..322ad42f8b --- /dev/null +++ b/apps/web/src/lib/threadEviction.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { selectThreadsToEvict, EVICTION_KEEP_COUNT, type EvictableThread } from "./threadEviction"; + +function makeEvictable(id: string, overrides: Partial = {}): EvictableThread { + return { + id, + hydrated: true, + isActive: false, + hasRunningSession: false, + updatedAt: "2026-01-01T00:00:00.000Z", + messageCount: 0, + activityCount: 0, + ...overrides, + }; +} + +describe("selectThreadsToEvict", () => { + it("returns empty array when thread count is within keep limit", () => { + const threads = Array.from({ length: EVICTION_KEEP_COUNT }, (_, i) => makeEvictable(`t-${i}`)); + expect(selectThreadsToEvict(threads, "t-0")).toEqual([]); + }); + + it("never evicts the active thread", () => { + const threads = Array.from({ length: EVICTION_KEEP_COUNT + 5 }, (_, i) => + makeEvictable(`t-${i}`), + ); + const result = selectThreadsToEvict(threads, "t-0"); + expect(result).not.toContain("t-0"); + }); + + it("never evicts threads with running sessions", () => { + const threads = [ + ...Array.from({ length: EVICTION_KEEP_COUNT + 3 }, (_, i) => makeEvictable(`t-${i}`)), + makeEvictable("t-running", { hasRunningSession: true }), + ]; + const result = selectThreadsToEvict(threads, "t-0"); + expect(result).not.toContain("t-running"); + }); + + it("evicts oldest idle threads first", () => { + const threads = [ + makeEvictable("t-old", { updatedAt: "2026-01-01T00:00:00.000Z" }), + makeEvictable("t-new", { updatedAt: "2026-04-01T00:00:00.000Z" }), + ...Array.from({ length: EVICTION_KEEP_COUNT }, (_, i) => + makeEvictable(`t-keep-${i}`, { updatedAt: "2026-03-01T00:00:00.000Z" }), + ), + ]; + const result = selectThreadsToEvict(threads, "t-new"); + expect(result).toContain("t-old"); + expect(result).not.toContain("t-new"); + }); + + it("skips already-dehydrated threads", () => { + const threads = [ + makeEvictable("t-dehydrated", { hydrated: false }), + ...Array.from({ length: EVICTION_KEEP_COUNT + 2 }, (_, i) => makeEvictable(`t-${i}`)), + ]; + const result = selectThreadsToEvict(threads, "t-0"); + expect(result).not.toContain("t-dehydrated"); + }); +}); diff --git a/apps/web/src/lib/threadEviction.ts b/apps/web/src/lib/threadEviction.ts new file mode 100644 index 0000000000..26235495d5 --- /dev/null +++ b/apps/web/src/lib/threadEviction.ts @@ -0,0 +1,47 @@ +/** + * Thread eviction policy for renderer memory management. + * + * Decides which threads should have their heavy data (messages, activities, + * proposedPlans, turnDiffSummaries) dropped from the Zustand store. + * Sidebar metadata is always retained. + */ + +/** How many fully-hydrated threads to keep in memory at once. */ +export const EVICTION_KEEP_COUNT = 5; + +export interface EvictableThread { + id: string; + hydrated: boolean; + isActive: boolean; + hasRunningSession: boolean; + updatedAt: string; + messageCount: number; + activityCount: number; +} + +/** + * Returns the IDs of threads that should be evicted (dehydrated). + * Never evicts: the active thread, threads with running sessions, + * or already-dehydrated threads. + */ +export function selectThreadsToEvict( + threads: ReadonlyArray, + activeThreadId: string | null, +): string[] { + const evictable = threads.filter( + (t) => t.hydrated && !t.isActive && t.id !== activeThreadId && !t.hasRunningSession, + ); + + const hydratedCount = threads.filter((t) => t.hydrated).length; + + if (hydratedCount <= EVICTION_KEEP_COUNT) { + return []; + } + + const toEvictCount = hydratedCount - EVICTION_KEEP_COUNT; + + // Sort by updatedAt ascending — oldest idle threads get evicted first + const sorted = evictable.toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)); + + return sorted.slice(0, toEvictCount).map((t) => t.id); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8f3667d937..763c0021c5 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -49,6 +49,7 @@ import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; import { deriveReplayRetryDecision } from "../orchestrationRecovery"; import { getWsRpcClient } from "~/wsRpcClient"; +import { selectThreadsToEvict, type EvictableThread } from "~/lib/threadEviction"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -588,5 +589,31 @@ function EventRouter() { useServerWelcomeSubscription(handleWelcome); useServerConfigUpdatedSubscription(handleServerConfigUpdated); + const evictThread = useStore((store) => store.evictThreadData); + const allThreads = useStore((store) => store.threads); + + // Evict inactive threads when navigating to keep memory bounded. + // This is a separate effect from the WS subscription lifecycle. + useEffect(() => { + const activeThreadId = pathname.startsWith("/chat/") + ? (pathname.split("/chat/")[1]?.split("/")[0] ?? null) + : null; + + const evictable: EvictableThread[] = allThreads.map((t) => ({ + id: t.id, + hydrated: t.hydrated, + isActive: t.id === activeThreadId, + hasRunningSession: t.session?.status === "running", + updatedAt: t.updatedAt ?? t.createdAt, + messageCount: t.messages.length, + activityCount: t.activities.length, + })); + + const idsToEvict = selectThreadsToEvict(evictable, activeThreadId); + for (const id of idsToEvict) { + evictThread(id as any); + } + }, [pathname, evictThread, allThreads]); + return null; } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 9deb578d3e..4d0813c61b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, + evictThreadData, + hydrateThread, syncServerReadModel, type AppState, } from "./store"; @@ -42,6 +44,7 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + hydrated: true, ...overrides, }; } @@ -835,3 +838,63 @@ describe("incremental orchestration updates", () => { expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); }); + +describe("thread eviction", () => { + it("evictThreadData clears messages and activities but preserves metadata", () => { + const thread = makeThread({ + messages: [ + { + id: MessageId.makeUnsafe("msg-1"), + role: "user", + text: "hello", + createdAt: "2026-02-27T00:00:00.000Z", + streaming: false, + }, + ], + activities: [ + { + id: "act-1", + type: "tool_use", + turnId: TurnId.makeUnsafe("turn-1"), + payload: { tool: "bash", detail: "echo big output" }, + createdAt: "2026-02-27T00:00:00.000Z", + } as any, + ], + }); + const state = makeState(thread); + const next = evictThreadData(state, thread.id); + const evicted = next.threads.find((t) => t.id === thread.id)!; + expect(evicted.messages).toEqual([]); + expect(evicted.activities).toEqual([]); + expect(evicted.proposedPlans).toEqual([]); + expect(evicted.turnDiffSummaries).toEqual([]); + expect(evicted.hydrated).toBe(false); + expect(evicted.title).toBe(thread.title); + expect(evicted.id).toBe(thread.id); + }); + + it("hydrateThread replaces dehydrated thread with full data", () => { + const thread = makeThread({ id: ThreadId.makeUnsafe("t-dry") }); + const state = makeState(thread); + const dehydrated = evictThreadData(state, thread.id); + const fullThread = makeReadModelThread({ + id: ThreadId.makeUnsafe("t-dry"), + messages: [ + { + id: MessageId.makeUnsafe("msg-1"), + role: "user" as const, + text: "hello", + turnId: null, + streaming: false, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + ], + }); + const next = hydrateThread(dehydrated, thread.id, fullThread); + const hydrated = next.threads.find((t) => t.id === thread.id)!; + expect(hydrated.hydrated).toBe(true); + expect(hydrated.messages).toHaveLength(1); + expect(hydrated.messages[0]!.text).toBe("hello"); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 6e768c4ef8..bd2d878eb0 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -176,6 +176,7 @@ function mapThread(thread: OrchestrationThread): Thread { worktreePath: thread.worktreePath, turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), activities: thread.activities.map((activity) => ({ ...activity })), + hydrated: true, }; } @@ -1140,6 +1141,31 @@ export function setThreadBranch( }); } +export function evictThreadData(state: AppState, threadId: ThreadId): AppState { + return updateThreadState(state, threadId, (thread) => ({ + ...thread, + messages: [], + activities: [], + proposedPlans: [], + turnDiffSummaries: [], + hydrated: false, + })); +} + +export function hydrateThread( + state: AppState, + threadId: ThreadId, + serverThread: OrchestrationReadModel["threads"][number], +): AppState { + const fresh = mapThread(serverThread); + return updateThreadState(state, threadId, (existing) => ({ + ...fresh, + // Preserve any client-only state + session: existing.session ?? fresh.session, + hydrated: true, + })); +} + // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { @@ -1148,6 +1174,11 @@ interface AppStore extends AppState { applyOrchestrationEvents: (events: ReadonlyArray) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; + evictThreadData: (threadId: ThreadId) => void; + hydrateThread: ( + threadId: ThreadId, + serverThread: OrchestrationReadModel["threads"][number], + ) => void; } export const useStore = create((set) => ({ @@ -1158,4 +1189,7 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), + evictThreadData: (threadId) => set((state) => evictThreadData(state, threadId)), + hydrateThread: (threadId, serverThread) => + set((state) => hydrateThread(state, threadId, serverThread)), })); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0599b9c989..6a008c2b0c 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -109,6 +109,8 @@ export interface Thread { worktreePath: string | null; turnDiffSummaries: TurnDiffSummary[]; activities: OrchestrationThreadActivity[]; + /** Whether this thread's full data (messages, activities, etc.) is loaded in memory. */ + hydrated: boolean; } export interface SidebarThreadSummary { diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..2fa1b9a887 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -27,6 +27,7 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + hydrated: true, ...overrides, }; }