Skip to content

Commit 890696c

Browse files
authored
fix: Stop setup discovery wedge and IPC fan-out at launch (#2257)
1 parent ee6a2ca commit 890696c

15 files changed

Lines changed: 243 additions & 67 deletions

File tree

apps/code/src/main/db/repositories/workspace-repository.mock.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository {
4949
taskIndex.set(workspace.taskId, workspace.id);
5050
return { ...workspace };
5151
},
52+
createCloudMany: (taskIds: string[]) => {
53+
const now = new Date().toISOString();
54+
for (const taskId of taskIds) {
55+
const workspace: Workspace = {
56+
id: crypto.randomUUID(),
57+
taskId,
58+
repositoryId: null,
59+
mode: "cloud",
60+
pinnedAt: null,
61+
lastViewedAt: null,
62+
lastActivityAt: null,
63+
linkedBranch: null,
64+
createdAt: now,
65+
updatedAt: now,
66+
};
67+
workspaces.set(workspace.id, workspace);
68+
taskIndex.set(workspace.taskId, workspace.id);
69+
}
70+
},
5271
deleteByTaskId: (taskId: string) => {
5372
const id = taskIndex.get(taskId);
5473
if (id) {

apps/code/src/main/db/repositories/workspace-repository.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface IWorkspaceRepository {
2121
findAllPinned(): Workspace[];
2222
findAll(): Workspace[];
2323
create(data: CreateWorkspaceData): Workspace;
24+
createCloudMany(taskIds: string[]): void;
2425
deleteByTaskId(taskId: string): void;
2526
deleteById(id: string): void;
2627
updatePinnedAt(taskId: string, pinnedAt: string | null): void;
@@ -98,6 +99,20 @@ export class WorkspaceRepository implements IWorkspaceRepository {
9899
return created;
99100
}
100101

102+
createCloudMany(taskIds: string[]): void {
103+
if (taskIds.length === 0) return;
104+
const timestamp = now();
105+
const rows: NewWorkspace[] = taskIds.map((taskId) => ({
106+
id: crypto.randomUUID(),
107+
taskId,
108+
repositoryId: null,
109+
mode: "cloud",
110+
createdAt: timestamp,
111+
updatedAt: timestamp,
112+
}));
113+
this.db.insert(workspaces).values(rows).run();
114+
}
115+
101116
deleteByTaskId(taskId: string): void {
102117
this.db.delete(workspaces).where(byTaskId(taskId)).run();
103118
}

apps/code/src/main/services/enrichment/service.ts

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,15 @@ const STALE_FLAG_SUGGESTION_CAP = 4;
8484
const STALE_FLAG_REFERENCES_PER_FLAG = 5;
8585
const STALE_LOOKBACK_DAYS = 30;
8686

87-
// Yields to the event loop between batches; without this, parse-heavy scans
88-
// freeze IPC / UI on the main process.
89-
const SCAN_BATCH_SIZE = 32;
87+
// Tree-sitter parse() is synchronous and runs on the main process event
88+
// loop. To keep IPC responsive we (1) yield after every file (not every
89+
// batch), (2) skip files past a size threshold — they're almost always
90+
// minified bundles or generated code where parsing buys nothing, and
91+
// (3) cap total parsed files so a monorepo (e.g. PostHog itself) doesn't
92+
// stall boot for tens of seconds. When the cap trips we fall back to
93+
// manifest-only install detection rather than failing outright.
94+
const MAX_FILE_BYTES = 256 * 1024;
95+
const MAX_FILES_TO_PARSE = 500;
9096

9197
interface ParsedRepoEntry {
9298
langId: string;
@@ -102,21 +108,6 @@ function yieldToEventLoop(): Promise<void> {
102108
return new Promise<void>((resolve) => setImmediate(resolve));
103109
}
104110

105-
async function processInBatches<T, R>(
106-
items: T[],
107-
batchSize: number,
108-
fn: (item: T) => Promise<R>,
109-
): Promise<R[]> {
110-
const out: R[] = [];
111-
for (let i = 0; i < items.length; i += batchSize) {
112-
const batch = items.slice(i, i + batchSize);
113-
const batchResults = await Promise.all(batch.map(fn));
114-
for (const r of batchResults) out.push(r);
115-
await yieldToEventLoop();
116-
}
117-
return out;
118-
}
119-
120111
function shouldSkipPath(relPath: string): boolean {
121112
const parts = relPath.split(/[\\/]/);
122113
return parts.some((segment) => SKIP_PATH_SEGMENTS.has(segment));
@@ -352,23 +343,40 @@ export class EnrichmentService {
352343
const langId = langIdMap[ext];
353344
if (!langId || !enricher.isSupported(langId)) continue;
354345
toParse.push({ relPath, langId });
346+
if (toParse.length >= MAX_FILES_TO_PARSE) {
347+
log.info("Capping repo parse to keep main process responsive", {
348+
repoPath,
349+
totalCandidates: posthogFiles.length,
350+
parseLimit: MAX_FILES_TO_PARSE,
351+
});
352+
break;
353+
}
355354
}
356355

357356
const files = new Map<string, ParsedRepoEntry>();
358-
await processInBatches(toParse, SCAN_BATCH_SIZE, async (candidate) => {
357+
// Serial with a yield after every file. Tree-sitter parse() is sync CPU
358+
// on the event loop; batching with Promise.all stacked all parses in one
359+
// synchronous burst between yields, which froze IPC. Per-file yields cap
360+
// each blocking window at one file's parse cost.
361+
for (const candidate of toParse) {
362+
const absPath = path.join(repoPath, candidate.relPath);
359363
let content: string;
360364
try {
361-
content = await fs.readFile(
362-
path.join(repoPath, candidate.relPath),
363-
"utf-8",
364-
);
365+
const stat = await fs.stat(absPath);
366+
if (stat.size > MAX_FILE_BYTES) {
367+
files.set(candidate.relPath, {
368+
langId: candidate.langId,
369+
result: null,
370+
});
371+
continue;
372+
}
373+
content = await fs.readFile(absPath, "utf-8");
365374
} catch {
366-
return null;
375+
continue;
367376
}
368377
try {
369378
const result = await enricher.parse(content, candidate.langId);
370379
files.set(candidate.relPath, { langId: candidate.langId, result });
371-
return null;
372380
} catch (err) {
373381
log.debug("enricher.parse threw during repo scan, skipping file", {
374382
file: candidate.relPath,
@@ -378,9 +386,9 @@ export class EnrichmentService {
378386
langId: candidate.langId,
379387
result: null,
380388
});
381-
return null;
382389
}
383-
});
390+
await yieldToEventLoop();
391+
}
384392

385393
const entry: ParsedRepoCacheEntry = { files, manifestHit };
386394
this.repoScanCache.set(repoPath, entry);

apps/code/src/main/services/llm-gateway/service.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,17 @@ export class LlmGatewayService {
4444
system?: string;
4545
maxTokens?: number;
4646
model?: string;
47+
signal?: AbortSignal;
48+
timeoutMs?: number;
4749
} = {},
4850
): Promise<PromptOutput> {
49-
const { system, maxTokens, model = "claude-haiku-4-5" } = options;
51+
const {
52+
system,
53+
maxTokens,
54+
model = "claude-haiku-4-5",
55+
signal,
56+
timeoutMs = 60_000,
57+
} = options;
5058

5159
const auth = await this.authService.getValidAccessToken();
5260
const gatewayUrl = getLlmGatewayUrl(auth.apiHost);
@@ -72,17 +80,38 @@ export class LlmGatewayService {
7280
messageCount: messages.length,
7381
});
7482

75-
const response = await this.authService.authenticatedFetch(
76-
fetch,
77-
messagesUrl,
78-
{
83+
const timeoutController = new AbortController();
84+
const timeoutId = setTimeout(() => {
85+
timeoutController.abort();
86+
}, timeoutMs);
87+
const onCallerAbort = () => timeoutController.abort();
88+
if (signal) {
89+
if (signal.aborted) timeoutController.abort();
90+
else signal.addEventListener("abort", onCallerAbort, { once: true });
91+
}
92+
93+
let response: Response;
94+
try {
95+
response = await this.authService.authenticatedFetch(fetch, messagesUrl, {
7996
method: "POST",
8097
headers: {
8198
"Content-Type": "application/json",
8299
},
83100
body: JSON.stringify(requestBody),
84-
},
85-
);
101+
signal: timeoutController.signal,
102+
});
103+
} catch (err) {
104+
if (timeoutController.signal.aborted && !signal?.aborted) {
105+
throw new LlmGatewayError(
106+
`LLM gateway request timed out after ${timeoutMs}ms`,
107+
"timeout",
108+
);
109+
}
110+
throw err;
111+
} finally {
112+
clearTimeout(timeoutId);
113+
signal?.removeEventListener("abort", onCallerAbort);
114+
}
86115

87116
if (!response.ok) {
88117
const errorBody = await response.text();

apps/code/src/main/services/workspace/schemas.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export const createWorkspaceInput = z
5555
},
5656
);
5757

58+
export const reconcileCloudWorkspacesInput = z.object({
59+
taskIds: z.array(z.string()),
60+
});
61+
62+
export const reconcileCloudWorkspacesOutput = z.object({
63+
created: z.array(z.string()),
64+
});
65+
5866
export const deleteWorkspaceInput = z.object({
5967
taskId: z.string(),
6068
mainRepoPath: z.string(),
@@ -264,6 +272,12 @@ export type WorkspaceInfo = z.infer<typeof workspaceInfoSchema>;
264272
export type Workspace = z.infer<typeof workspaceSchema>;
265273

266274
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceInput>;
275+
export type ReconcileCloudWorkspacesInput = z.infer<
276+
typeof reconcileCloudWorkspacesInput
277+
>;
278+
export type ReconcileCloudWorkspacesOutput = z.infer<
279+
typeof reconcileCloudWorkspacesOutput
280+
>;
267281
export type DeleteWorkspaceInput = z.infer<typeof deleteWorkspaceInput>;
268282
export type VerifyWorkspaceInput = z.infer<typeof verifyWorkspaceInput>;
269283
export type GetWorkspaceInfoInput = z.infer<typeof getWorkspaceInfoInput>;

apps/code/src/main/services/workspace/service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type {
3838
BranchChangedPayload,
3939
CreateWorkspaceInput,
4040
LinkedBranchChangedPayload,
41+
ReconcileCloudWorkspacesOutput,
4142
Workspace,
4243
WorkspaceErrorPayload,
4344
WorkspaceInfo,
@@ -433,6 +434,30 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
433434
}
434435
}
435436

437+
// Batched cloud-workspace reconcile. The renderer calls this once on boot
438+
// with every cloud taskId it sees that has no local workspace row, instead
439+
// of firing one createWorkspace mutation per task. With 100+ cloud tasks
440+
// the N-call pattern saturates the main thread on the tRPC IPC path; this
441+
// collapses it to one IPC + one batched insert.
442+
async reconcileCloudWorkspaces(
443+
taskIds: string[],
444+
): Promise<ReconcileCloudWorkspacesOutput> {
445+
if (taskIds.length === 0) return { created: [] };
446+
447+
const existingTaskIds = new Set(
448+
this.workspaceRepo.findAll().map((w) => w.taskId),
449+
);
450+
const uniqueRequested = Array.from(new Set(taskIds));
451+
const toCreate = uniqueRequested.filter((id) => !existingTaskIds.has(id));
452+
if (toCreate.length === 0) return { created: [] };
453+
454+
log.info(
455+
`Reconciling ${toCreate.length} cloud workspaces (requested ${taskIds.length})`,
456+
);
457+
this.workspaceRepo.createCloudMany(toCreate);
458+
return { created: toCreate };
459+
}
460+
436461
async createWorkspace(options: CreateWorkspaceInput): Promise<WorkspaceInfo> {
437462
// Prevent concurrent workspace creation for the same task
438463
const existingPromise = this.creatingWorkspaces.get(options.taskId);

apps/code/src/main/trpc/routers/workspace.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
listGitWorktreesOutput,
2828
markActivityInput,
2929
markViewedInput,
30+
reconcileCloudWorkspacesInput,
31+
reconcileCloudWorkspacesOutput,
3032
taskPrStatusInput,
3133
taskPrStatusOutput,
3234
togglePinInput,
@@ -66,6 +68,13 @@ export const workspaceRouter = router({
6668
.output(createWorkspaceOutput)
6769
.mutation(({ input }) => getService().createWorkspace(input)),
6870

71+
reconcileCloudWorkspaces: publicProcedure
72+
.input(reconcileCloudWorkspacesInput)
73+
.output(reconcileCloudWorkspacesOutput)
74+
.mutation(({ input }) =>
75+
getService().reconcileCloudWorkspaces(input.taskIds),
76+
),
77+
6978
delete: publicProcedure
7079
.input(deleteWorkspaceInput)
7180
.mutation(({ input }) =>

apps/code/src/renderer/components/MainLayout.tsx

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -97,34 +97,24 @@ export function MainLayout() {
9797
!reconcilingTaskIds.current.has(t.id),
9898
);
9999
if (missing.length === 0) return;
100-
for (const t of missing) reconcilingTaskIds.current.add(t.id);
101-
void Promise.allSettled(
102-
missing.map((t) =>
103-
workspaceApi.create({
104-
taskId: t.id,
105-
mainRepoPath: "",
106-
folderId: "",
107-
folderPath: "",
108-
mode: "cloud",
109-
}),
110-
),
111-
).then((results) => {
112-
let anySucceeded = false;
113-
for (const [i, r] of results.entries()) {
114-
const id = missing[i].id;
115-
reconcilingTaskIds.current.delete(id);
116-
if (r.status === "rejected") {
117-
log.warn(`Failed to reconcile workspace for task ${id}`, r.reason);
118-
} else {
119-
anySucceeded = true;
100+
const missingIds = missing.map((t) => t.id);
101+
for (const id of missingIds) reconcilingTaskIds.current.add(id);
102+
// Single batched IPC instead of one mutation per task — with many cloud
103+
// tasks the per-task pattern saturates the main thread at boot.
104+
workspaceApi
105+
.reconcileCloudWorkspaces(missingIds)
106+
.then((result) => {
107+
for (const id of missingIds) reconcilingTaskIds.current.delete(id);
108+
if (result.created.length > 0) {
109+
void queryClient.invalidateQueries(
110+
trpcReact.workspace.getAll.pathFilter(),
111+
);
120112
}
121-
}
122-
if (anySucceeded) {
123-
void queryClient.invalidateQueries(
124-
trpcReact.workspace.getAll.pathFilter(),
125-
);
126-
}
127-
});
113+
})
114+
.catch((err) => {
115+
for (const id of missingIds) reconcilingTaskIds.current.delete(id);
116+
log.warn("Failed to reconcile cloud workspaces", err);
117+
});
128118
}, [
129119
syncCloudTasksEnabled,
130120
tasks,

apps/code/src/renderer/features/command/components/CommandMenu.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function TaskCommandIcon({ task }: { task: Task }) {
6262
const { prState, hasDiff } = useTaskPrStatus({
6363
id: task.id,
6464
cloudPrUrl: null,
65+
taskRunEnvironment: task.latest_run?.environment,
6566
});
6667
return (
6768
<TaskIcon

apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export function useSetupDiscovery() {
1313

1414
useEffect(() => {
1515
if (startedRef.current) return;
16-
if (discoveryStatus === "done") return;
16+
// Only auto-fire from a clean "idle" state. "done" needs no rerun, and
17+
// "error" (which now includes interrupted runs persisted across boots —
18+
// see setupStore partialize) requires an explicit user retry to recover.
19+
if (discoveryStatus !== "idle") return;
1720
if (!selectedDirectory) return;
1821

1922
startedRef.current = true;

0 commit comments

Comments
 (0)