From da6182bf8897d92553ec177a3a3969ff8ab44c91 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 08:23:16 -0700 Subject: [PATCH 1/2] Stop setup discovery from wedging on boot --- .../src/main/services/enrichment/service.ts | 62 +++++++++++-------- .../src/main/services/llm-gateway/service.ts | 43 ++++++++++--- .../features/setup/hooks/useSetupDiscovery.ts | 5 +- .../setup/services/setupRunService.ts | 7 +++ .../features/setup/stores/setupStore.ts | 12 +++- 5 files changed, 93 insertions(+), 36 deletions(-) diff --git a/apps/code/src/main/services/enrichment/service.ts b/apps/code/src/main/services/enrichment/service.ts index 6a2a0b489b..e859d2ecc2 100644 --- a/apps/code/src/main/services/enrichment/service.ts +++ b/apps/code/src/main/services/enrichment/service.ts @@ -84,9 +84,15 @@ const STALE_FLAG_SUGGESTION_CAP = 4; const STALE_FLAG_REFERENCES_PER_FLAG = 5; const STALE_LOOKBACK_DAYS = 30; -// Yields to the event loop between batches; without this, parse-heavy scans -// freeze IPC / UI on the main process. -const SCAN_BATCH_SIZE = 32; +// Tree-sitter parse() is synchronous and runs on the main process event +// loop. To keep IPC responsive we (1) yield after every file (not every +// batch), (2) skip files past a size threshold — they're almost always +// minified bundles or generated code where parsing buys nothing, and +// (3) cap total parsed files so a monorepo (e.g. PostHog itself) doesn't +// stall boot for tens of seconds. When the cap trips we fall back to +// manifest-only install detection rather than failing outright. +const MAX_FILE_BYTES = 256 * 1024; +const MAX_FILES_TO_PARSE = 500; interface ParsedRepoEntry { langId: string; @@ -102,21 +108,6 @@ function yieldToEventLoop(): Promise { return new Promise((resolve) => setImmediate(resolve)); } -async function processInBatches( - items: T[], - batchSize: number, - fn: (item: T) => Promise, -): Promise { - const out: R[] = []; - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize); - const batchResults = await Promise.all(batch.map(fn)); - for (const r of batchResults) out.push(r); - await yieldToEventLoop(); - } - return out; -} - function shouldSkipPath(relPath: string): boolean { const parts = relPath.split(/[\\/]/); return parts.some((segment) => SKIP_PATH_SEGMENTS.has(segment)); @@ -352,23 +343,40 @@ export class EnrichmentService { const langId = langIdMap[ext]; if (!langId || !enricher.isSupported(langId)) continue; toParse.push({ relPath, langId }); + if (toParse.length >= MAX_FILES_TO_PARSE) { + log.info("Capping repo parse to keep main process responsive", { + repoPath, + totalCandidates: posthogFiles.length, + parseLimit: MAX_FILES_TO_PARSE, + }); + break; + } } const files = new Map(); - await processInBatches(toParse, SCAN_BATCH_SIZE, async (candidate) => { + // Serial with a yield after every file. Tree-sitter parse() is sync CPU + // on the event loop; batching with Promise.all stacked all parses in one + // synchronous burst between yields, which froze IPC. Per-file yields cap + // each blocking window at one file's parse cost. + for (const candidate of toParse) { + const absPath = path.join(repoPath, candidate.relPath); let content: string; try { - content = await fs.readFile( - path.join(repoPath, candidate.relPath), - "utf-8", - ); + const stat = await fs.stat(absPath); + if (stat.size > MAX_FILE_BYTES) { + files.set(candidate.relPath, { + langId: candidate.langId, + result: null, + }); + continue; + } + content = await fs.readFile(absPath, "utf-8"); } catch { - return null; + continue; } try { const result = await enricher.parse(content, candidate.langId); files.set(candidate.relPath, { langId: candidate.langId, result }); - return null; } catch (err) { log.debug("enricher.parse threw during repo scan, skipping file", { file: candidate.relPath, @@ -378,9 +386,9 @@ export class EnrichmentService { langId: candidate.langId, result: null, }); - return null; } - }); + await yieldToEventLoop(); + } const entry: ParsedRepoCacheEntry = { files, manifestHit }; this.repoScanCache.set(repoPath, entry); diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts index 94f22e19ab..6f2ac87e93 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -44,9 +44,17 @@ export class LlmGatewayService { system?: string; maxTokens?: number; model?: string; + signal?: AbortSignal; + timeoutMs?: number; } = {}, ): Promise { - const { system, maxTokens, model = "claude-haiku-4-5" } = options; + const { + system, + maxTokens, + model = "claude-haiku-4-5", + signal, + timeoutMs = 60_000, + } = options; const auth = await this.authService.getValidAccessToken(); const gatewayUrl = getLlmGatewayUrl(auth.apiHost); @@ -72,17 +80,38 @@ export class LlmGatewayService { messageCount: messages.length, }); - const response = await this.authService.authenticatedFetch( - fetch, - messagesUrl, - { + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => { + timeoutController.abort(); + }, timeoutMs); + const onCallerAbort = () => timeoutController.abort(); + if (signal) { + if (signal.aborted) timeoutController.abort(); + else signal.addEventListener("abort", onCallerAbort, { once: true }); + } + + let response: Response; + try { + response = await this.authService.authenticatedFetch(fetch, messagesUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(requestBody), - }, - ); + signal: timeoutController.signal, + }); + } catch (err) { + if (timeoutController.signal.aborted && !signal?.aborted) { + throw new LlmGatewayError( + `LLM gateway request timed out after ${timeoutMs}ms`, + "timeout", + ); + } + throw err; + } finally { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onCallerAbort); + } if (!response.ok) { const errorBody = await response.text(); diff --git a/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts index a69530e1cd..7281f0bccc 100644 --- a/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts +++ b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts @@ -13,7 +13,10 @@ export function useSetupDiscovery() { useEffect(() => { if (startedRef.current) return; - if (discoveryStatus === "done") return; + // Only auto-fire from a clean "idle" state. "done" needs no rerun, and + // "error" (which now includes interrupted runs persisted across boots — + // see setupStore partialize) requires an explicit user retry to recover. + if (discoveryStatus !== "idle") return; if (!selectedDirectory) return; startedRef.current = true; diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts index 920ccc082e..71f63f5766 100644 --- a/apps/code/src/renderer/features/setup/services/setupRunService.ts +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -240,6 +240,13 @@ export class SetupRunService { private enricherSuggestionsRunning = false; startSetup(directory: string): void { + // Defense in depth: never auto-run from a non-idle persisted state. + // The hook (useSetupDiscovery) is the primary gate, but a direct call + // path could otherwise re-enter the loop that wedged users on boot — + // creating fresh cloud tasks and a tree-sitter parse storm against the + // user's repo on every launch. + const status = useSetupStore.getState().discoveryStatus; + if (status !== "idle") return; this.injectEnricherSuggestions(directory); this.startDiscovery(directory); } diff --git a/apps/code/src/renderer/features/setup/stores/setupStore.ts b/apps/code/src/renderer/features/setup/stores/setupStore.ts index 5e42e2b464..4a7ac75534 100644 --- a/apps/code/src/renderer/features/setup/stores/setupStore.ts +++ b/apps/code/src/renderer/features/setup/stores/setupStore.ts @@ -206,12 +206,22 @@ export const useSetupStore = create()( }), { name: "setup-store", + // Persist "running" as "error" so an interrupted run (crash, force-quit, + // freeze) doesn't auto-restart on next boot. Otherwise discovery loops + // forever, creating new cloud tasks and spawning agents on every launch. partialize: (state) => ({ discoveredTasks: state.discoveredTasks, discoveryStatus: state.discoveryStatus === "done" ? ("done" as const) - : ("idle" as const), + : state.discoveryStatus === "running" || + state.discoveryStatus === "error" + ? ("error" as const) + : ("idle" as const), + error: + state.discoveryStatus === "running" + ? "Discovery was interrupted. You can skip or retry." + : state.error, }), }, ), From 6514fb5634f7989325775f1b09ee6e92ee8f9bd4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 08:58:16 -0700 Subject: [PATCH 2/2] Reduce boot IPC fan-out for cloud tasks --- .../repositories/workspace-repository.mock.ts | 19 ++++++++ .../db/repositories/workspace-repository.ts | 15 +++++++ .../src/main/services/workspace/schemas.ts | 14 ++++++ .../src/main/services/workspace/service.ts | 25 +++++++++++ apps/code/src/main/trpc/routers/workspace.ts | 9 ++++ .../src/renderer/components/MainLayout.tsx | 44 +++++++------------ .../command/components/CommandMenu.tsx | 1 + .../sidebar/hooks/useTaskPrStatus.test.ts | 39 +++++++++++++++- .../features/sidebar/hooks/useTaskPrStatus.ts | 9 +++- .../features/workspace/hooks/useWorkspace.ts | 6 +++ 10 files changed, 150 insertions(+), 31 deletions(-) diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 7be3ade37f..36106f6d2e 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -49,6 +49,25 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { taskIndex.set(workspace.taskId, workspace.id); return { ...workspace }; }, + createCloudMany: (taskIds: string[]) => { + const now = new Date().toISOString(); + for (const taskId of taskIds) { + const workspace: Workspace = { + id: crypto.randomUUID(), + taskId, + repositoryId: null, + mode: "cloud", + pinnedAt: null, + lastViewedAt: null, + lastActivityAt: null, + linkedBranch: null, + createdAt: now, + updatedAt: now, + }; + workspaces.set(workspace.id, workspace); + taskIndex.set(workspace.taskId, workspace.id); + } + }, deleteByTaskId: (taskId: string) => { const id = taskIndex.get(taskId); if (id) { diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 6dfcb391fe..7a821c0d90 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -21,6 +21,7 @@ export interface IWorkspaceRepository { findAllPinned(): Workspace[]; findAll(): Workspace[]; create(data: CreateWorkspaceData): Workspace; + createCloudMany(taskIds: string[]): void; deleteByTaskId(taskId: string): void; deleteById(id: string): void; updatePinnedAt(taskId: string, pinnedAt: string | null): void; @@ -98,6 +99,20 @@ export class WorkspaceRepository implements IWorkspaceRepository { return created; } + createCloudMany(taskIds: string[]): void { + if (taskIds.length === 0) return; + const timestamp = now(); + const rows: NewWorkspace[] = taskIds.map((taskId) => ({ + id: crypto.randomUUID(), + taskId, + repositoryId: null, + mode: "cloud", + createdAt: timestamp, + updatedAt: timestamp, + })); + this.db.insert(workspaces).values(rows).run(); + } + deleteByTaskId(taskId: string): void { this.db.delete(workspaces).where(byTaskId(taskId)).run(); } diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 79cafc1c04..2569bab385 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -55,6 +55,14 @@ export const createWorkspaceInput = z }, ); +export const reconcileCloudWorkspacesInput = z.object({ + taskIds: z.array(z.string()), +}); + +export const reconcileCloudWorkspacesOutput = z.object({ + created: z.array(z.string()), +}); + export const deleteWorkspaceInput = z.object({ taskId: z.string(), mainRepoPath: z.string(), @@ -264,6 +272,12 @@ export type WorkspaceInfo = z.infer; export type Workspace = z.infer; export type CreateWorkspaceInput = z.infer; +export type ReconcileCloudWorkspacesInput = z.infer< + typeof reconcileCloudWorkspacesInput +>; +export type ReconcileCloudWorkspacesOutput = z.infer< + typeof reconcileCloudWorkspacesOutput +>; export type DeleteWorkspaceInput = z.infer; export type VerifyWorkspaceInput = z.infer; export type GetWorkspaceInfoInput = z.infer; diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 10ddfc3638..851b627d43 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -38,6 +38,7 @@ import type { BranchChangedPayload, CreateWorkspaceInput, LinkedBranchChangedPayload, + ReconcileCloudWorkspacesOutput, Workspace, WorkspaceErrorPayload, WorkspaceInfo, @@ -433,6 +434,30 @@ export class WorkspaceService extends TypedEventEmitter } } + // Batched cloud-workspace reconcile. The renderer calls this once on boot + // with every cloud taskId it sees that has no local workspace row, instead + // of firing one createWorkspace mutation per task. With 100+ cloud tasks + // the N-call pattern saturates the main thread on the tRPC IPC path; this + // collapses it to one IPC + one batched insert. + async reconcileCloudWorkspaces( + taskIds: string[], + ): Promise { + if (taskIds.length === 0) return { created: [] }; + + const existingTaskIds = new Set( + this.workspaceRepo.findAll().map((w) => w.taskId), + ); + const uniqueRequested = Array.from(new Set(taskIds)); + const toCreate = uniqueRequested.filter((id) => !existingTaskIds.has(id)); + if (toCreate.length === 0) return { created: [] }; + + log.info( + `Reconciling ${toCreate.length} cloud workspaces (requested ${taskIds.length})`, + ); + this.workspaceRepo.createCloudMany(toCreate); + return { created: toCreate }; + } + async createWorkspace(options: CreateWorkspaceInput): Promise { // Prevent concurrent workspace creation for the same task const existingPromise = this.creatingWorkspaces.get(options.taskId); diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts index 97f44604f4..8e84c79534 100644 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ b/apps/code/src/main/trpc/routers/workspace.ts @@ -27,6 +27,8 @@ import { listGitWorktreesOutput, markActivityInput, markViewedInput, + reconcileCloudWorkspacesInput, + reconcileCloudWorkspacesOutput, taskPrStatusInput, taskPrStatusOutput, togglePinInput, @@ -66,6 +68,13 @@ export const workspaceRouter = router({ .output(createWorkspaceOutput) .mutation(({ input }) => getService().createWorkspace(input)), + reconcileCloudWorkspaces: publicProcedure + .input(reconcileCloudWorkspacesInput) + .output(reconcileCloudWorkspacesOutput) + .mutation(({ input }) => + getService().reconcileCloudWorkspaces(input.taskIds), + ), + delete: publicProcedure .input(deleteWorkspaceInput) .mutation(({ input }) => diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 7699229a93..70b0dee875 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -97,34 +97,24 @@ export function MainLayout() { !reconcilingTaskIds.current.has(t.id), ); if (missing.length === 0) return; - for (const t of missing) reconcilingTaskIds.current.add(t.id); - void Promise.allSettled( - missing.map((t) => - workspaceApi.create({ - taskId: t.id, - mainRepoPath: "", - folderId: "", - folderPath: "", - mode: "cloud", - }), - ), - ).then((results) => { - let anySucceeded = false; - for (const [i, r] of results.entries()) { - const id = missing[i].id; - reconcilingTaskIds.current.delete(id); - if (r.status === "rejected") { - log.warn(`Failed to reconcile workspace for task ${id}`, r.reason); - } else { - anySucceeded = true; + const missingIds = missing.map((t) => t.id); + for (const id of missingIds) reconcilingTaskIds.current.add(id); + // Single batched IPC instead of one mutation per task — with many cloud + // tasks the per-task pattern saturates the main thread at boot. + workspaceApi + .reconcileCloudWorkspaces(missingIds) + .then((result) => { + for (const id of missingIds) reconcilingTaskIds.current.delete(id); + if (result.created.length > 0) { + void queryClient.invalidateQueries( + trpcReact.workspace.getAll.pathFilter(), + ); } - } - if (anySucceeded) { - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - } - }); + }) + .catch((err) => { + for (const id of missingIds) reconcilingTaskIds.current.delete(id); + log.warn("Failed to reconcile cloud workspaces", err); + }); }, [ syncCloudTasksEnabled, tasks, diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 8551cd3053..006dc619a5 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -62,6 +62,7 @@ function TaskCommandIcon({ task }: { task: Task }) { const { prState, hasDiff } = useTaskPrStatus({ id: task.id, cloudPrUrl: null, + taskRunEnvironment: task.latest_run?.environment, }); return ( ({ useTRPC: () => ({ @@ -11,7 +12,7 @@ vi.mock("@renderer/trpc/client", () => ({ getTaskPrStatus: { queryOptions: ( input: { taskId: string; cloudPrUrl: string | null }, - opts: { staleTime: number }, + opts: { staleTime: number; enabled?: boolean }, ) => ({ queryKey: ["workspace.getTaskPrStatus", input], queryFn: () => undefined, @@ -23,7 +24,10 @@ vi.mock("@renderer/trpc/client", () => ({ })); vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: queryData }), + useQuery: (opts: { enabled?: boolean }) => { + lastQueryOptions = opts; + return { data: queryData }; + }, })); function makeTask(overrides: Partial = {}): TaskData { @@ -50,6 +54,7 @@ function makeTask(overrides: Partial = {}): TaskData { describe("useTaskPrStatus", () => { beforeEach(() => { queryData = undefined; + lastQueryOptions = undefined; }); it("returns empty status when no data is available", () => { @@ -80,4 +85,34 @@ describe("useTaskPrStatus", () => { const { result } = renderHook(() => useTaskPrStatus(makeTask())); expect(result.current).toEqual({ prState: "merged", hasDiff: true }); }); + + it("disables the query for cloud tasks without a cloudPrUrl", () => { + renderHook(() => + useTaskPrStatus( + makeTask({ taskRunEnvironment: "cloud", cloudPrUrl: null }), + ), + ); + expect(lastQueryOptions?.enabled).toBe(false); + }); + + it("runs the query for cloud tasks that have a cloudPrUrl", () => { + renderHook(() => + useTaskPrStatus( + makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: "https://github.com/x/y/pull/1", + }), + ), + ); + expect(lastQueryOptions?.enabled).toBe(true); + }); + + it("runs the query for local tasks", () => { + renderHook(() => + useTaskPrStatus( + makeTask({ taskRunEnvironment: "local", cloudPrUrl: null }), + ), + ); + expect(lastQueryOptions?.enabled).toBe(true); + }); }); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index cf034e96c0..bf8688bc56 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -13,14 +13,19 @@ const SIDEBAR_STALE_TIME = 60_000; const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; export function useTaskPrStatus( - task: Pick, + task: Pick, ): TaskPrStatus { const trpc = useTRPC(); + // Cloud tasks without a PR URL have nothing for the main process to look up + // — it returns EMPTY immediately. Skip the tRPC roundtrip so a sidebar full + // of cloud tasks doesn't fire one IPC per task on mount. + const skipQuery = task.taskRunEnvironment === "cloud" && !task.cloudPrUrl; + const { data } = useQuery( trpc.workspace.getTaskPrStatus.queryOptions( { taskId: task.id, cloudPrUrl: task.cloudPrUrl }, - { staleTime: SIDEBAR_STALE_TIME }, + { staleTime: SIDEBAR_STALE_TIME, enabled: !skipQuery }, ), ); diff --git a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts b/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts index 99c73e8e8e..f0932a1ee9 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts +++ b/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts @@ -177,6 +177,12 @@ export const workspaceApi = { return trpcClient.workspace.create.mutate(options); }, + async reconcileCloudWorkspaces( + taskIds: string[], + ): Promise<{ created: string[] }> { + return trpcClient.workspace.reconcileCloudWorkspaces.mutate({ taskIds }); + }, + async delete(taskId: string, mainRepoPath: string) { return trpcClient.workspace.delete.mutate({ taskId, mainRepoPath }); },