From 4457096074f6e5525257786af48ad50bf7aae793 Mon Sep 17 00:00:00 2001 From: mihai2mn Date: Wed, 8 Apr 2026 22:32:39 +0200 Subject: [PATCH 1/6] fix(desktop): auto-reload renderer on crash/OOM and cap V8 heap When the renderer hits V8 heap limit (~3.7GB) and crashes, the main process stays alive with the backend and agents still running. This adds a render-process-gone handler that auto-reloads the renderer after 500ms, restoring real-time visibility into running agent work. Also caps the renderer V8 heap at 2GB via --max-old-space-size so GC runs more aggressively instead of growing unchecked. Fixes #1686 Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/main.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..ce15ba7fad 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -287,6 +287,10 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { initializePackagedLogging(); +// Cap the renderer's V8 heap to 2 GB so GC runs more aggressively +// instead of growing to 3.7 GB+ and crashing with OOM. +app.commandLine.appendSwitch("js-flags", "--max-old-space-size=2048"); + if (process.platform === "linux") { app.commandLine.appendSwitch("class", LINUX_WM_CLASS); } @@ -1393,6 +1397,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); From 2c07bd653cf4031ae211b80962b504d151553ad8 Mon Sep 17 00:00:00 2001 From: mihai2mn Date: Wed, 8 Apr 2026 22:32:54 +0200 Subject: [PATCH 2/6] feat(web): add thread eviction policy for renderer memory management Pure logic module that decides which threads to dehydrate based on activity, session status, and recency. Keeps up to 5 fully-hydrated threads in memory; never evicts the active thread or threads with running agents. Oldest idle threads are evicted first. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/lib/threadEviction.test.ts | 61 +++++++++++++++++++++++++ apps/web/src/lib/threadEviction.ts | 47 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 apps/web/src/lib/threadEviction.test.ts create mode 100644 apps/web/src/lib/threadEviction.ts 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); +} From 23030cc3e88f7135fbef55a0b03f48f15e8b095a Mon Sep 17 00:00:00 2001 From: mihai2mn Date: Wed, 8 Apr 2026 22:33:10 +0200 Subject: [PATCH 3/6] feat(web): add thread eviction and hydration store actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads can be 'dehydrated' to free renderer memory — messages, activities, plans, and diffs are cleared while preserving sidebar metadata. They are re-hydrated from the server when the user navigates back. The hydrated flag on Thread tracks load state. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/components/ChatView.logic.test.ts | 4 ++ apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/Sidebar.logic.test.ts | 1 + apps/web/src/store.test.ts | 63 +++++++++++++++++++ apps/web/src/store.ts | 34 ++++++++++ apps/web/src/types.ts | 2 + apps/web/src/worktreeCleanup.test.ts | 1 + 7 files changed, 106 insertions(+) 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/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/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, }; } From 489dc1b3c474fcd5c9ae2c0a87756e6b96ee47a6 Mon Sep 17 00:00:00 2001 From: mihai2mn Date: Wed, 8 Apr 2026 22:33:18 +0200 Subject: [PATCH 4/6] feat(web): wire thread eviction into navigation lifecycle When navigating between threads, inactive threads beyond the keep limit (5) are dehydrated to free memory. When viewing a dehydrated thread, its data is re-fetched from the server automatically. Dehydrated threads show a loading indicator while data loads. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/components/ChatView.tsx | 30 ++++++++++++++++++++++++++++ apps/web/src/routes/__root.tsx | 27 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+) 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/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; } From 476f4b3a2e5099d34e876c68f775f1f326334493 Mon Sep 17 00:00:00 2001 From: mihai2mn Date: Thu, 9 Apr 2026 17:34:45 +0200 Subject: [PATCH 5/6] fix(desktop): ensure V8 heap cap applies to renderer process app.commandLine.appendSwitch("js-flags", ...) alone doesn't propagate to sandboxed renderer child processes. Add the flag via webPreferences.additionalArguments so the renderer's V8 heap is actually capped at 2GB. Without this, the renderer was observed growing to 4.7GB over 19 hours before crashing. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/main.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ce15ba7fad..40018d2057 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -287,8 +287,9 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { initializePackagedLogging(); -// Cap the renderer's V8 heap to 2 GB so GC runs more aggressively -// instead of growing to 3.7 GB+ and crashing with OOM. +// 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") { @@ -1358,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"], }, }); From e7b400cea684ce830b626be70b3817af6a7d724e Mon Sep 17 00:00:00 2001 From: mihai2mn Date: Thu, 9 Apr 2026 17:34:54 +0200 Subject: [PATCH 6/6] fix(server): add negative cache for missing worktree paths When a worktree directory is deleted, fileSystem.stat() was called on every 30-second git status poll with no caching, creating a tight ENOENT error loop with rapidly growing fiber IDs (observed up to #228210). This adds a 5-minute negative cache so missing paths are only re-checked periodically instead of hammered every poll cycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/server/src/git/Layers/GitCore.ts | 46 ++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) 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);