Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions src/main/api/deckApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<typeof buildProjectList> | 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 <T>(stage: keyof typeof spent, work: () => Promise<T> | T): Promise<T> => {
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);
Expand Down
17 changes: 17 additions & 0 deletions src/main/ipc.cockpit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
});
});
Loading