diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index c55b02b..31ed16b 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -63,6 +63,8 @@ export interface DeckApiBundle { } const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects'); +/** A project list slower than this is logged with where its time went (see projects:list). */ +const SLOW_PROJECT_LIST_MS = 20_000; const CLAUDE_TASKS = join(homedir(), '.claude', 'tasks'); const CODEX_SESSIONS = join(homedir(), '.codex', 'sessions'); const ANTIGRAVITY_DIR = join(homedir(), '.gemini', 'antigravity'); @@ -203,17 +205,40 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { lastUserMessage: (p, session) => getProvider(session.agentId).lastUserMessage(p, session.id), entry: (p) => cfg.store.get(p), }); - invoke('projects:list', allow('observe'), async () => { + // ONE build at a time, shared by every caller. The deck gives up on an answer after 150s and asks + // again on its next refresh, but giving up does not stop the walk here — so on a machine that had + // just booted (cold disk, every repository's git and the whole Codex store still to read) each retry + // started a second and third walk on top of the first, and none finished: SIHYEONG-4080 logged five + // 150s timeouts in a row on 2026-10-12. A late caller now joins the walk already under way. + let projectListInFlight: ReturnType | null = null; + invoke('projects:list', allow('observe'), () => { + if (projectListInFlight) return projectListInFlight; const scan = makeDeckScan(); - return buildProjectList({ - nowMs: Date.now(), + // Where a slow build spent its time, summed per stage across the worker pool — the only way to + // tell git from the transcript stores after the fact on a machine nobody was watching. + const spent = { scan: 0, git: 0, sessions: 0, cue: 0 }; + const timed = async (stage: keyof typeof spent, work: () => Promise | T): Promise => { + const t = Date.now(); + try { return await work(); } finally { spent[stage] += Date.now() - t; } + }; + const started = Date.now(); + let count = 0; + projectListInFlight = buildProjectList({ + nowMs: started, thresholds: effThresholds(), - scan: memoScan, - git: (dir) => getGitInfo(dir), - sessions: (p) => scan.sessions(p), - resumeCue: (p, session) => getProvider(session.agentId).lastUserMessage(p, session.id), + scan: () => timed('scan', memoScan), + git: (dir) => timed('git', () => getGitInfo(dir)), + sessions: (p) => timed('sessions', () => scan.sessions(p)), + resumeCue: (p, session) => timed('cue', () => getProvider(session.agentId).lastUserMessage(p, session.id)), getEntry: (p) => cfg.store.get(p), + }).then((list) => { count = list.length; return list; }).finally(() => { + projectListInFlight = null; + const ms = Date.now() - started; + if (ms >= SLOW_PROJECT_LIST_MS) { + cfg.diagnostics?.write('warn', 'deck', `projects:list took ${ms}ms for ${count} projects (summed: scan=${spent.scan} git=${spent.git} sessions=${spent.sessions} cue=${spent.cue}ms)`); + } }); + return projectListInFlight; }); invoke('project:memory', allow('observe'), (projectPath: string, fresh?: boolean) => { const projectPathString = String(projectPath); diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts index 140d07b..3e8019d 100644 --- a/src/main/ipc.cockpit.test.ts +++ b/src/main/ipc.cockpit.test.ts @@ -70,6 +70,7 @@ beforeAll(() => { store: { getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => storedAgent, getSessionSummary: () => true, getAiSessionSummary: () => false, + getThresholds: () => null, }, sendError: vi.fn(), defaultLanguage: 'en', @@ -374,3 +375,19 @@ describe('cockpit:conversationsLastUsed', () => { expect(await lastUsed(null, null)).toEqual([]); }); }); + +// The deck gives up on a slow list after 150s and asks again, but giving up does not stop the walk in +// the main process. Each retry used to start ANOTHER full walk on top of the first — on a machine that +// had just booted, none of them ever finished. +describe('projects:list', () => { + it('shares the build already under way instead of starting another', async () => { + const list = handlers.get('projects:list')!; + const first = list(null); + const second = list(null); + expect(second).toBe(first); + await first; + const third = list(null); + expect(third).not.toBe(first); // once it has landed, the next ask builds fresh + await third; + }); +});