fix: reduce codex recent project timeouts
This commit is contained in:
parent
9fe3343038
commit
d8fce1b3a3
3 changed files with 197 additions and 76 deletions
|
|
@ -6,6 +6,7 @@ import type { RecentProjectsSourcePort } from '@features/recent-projects/core/ap
|
|||
import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
|
||||
import type {
|
||||
CodexAppServerClient,
|
||||
CodexRecentThreadsResult,
|
||||
CodexThreadSummary,
|
||||
} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
||||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||
|
|
@ -14,9 +15,7 @@ import type { ServiceContext } from '@main/services';
|
|||
const CODEX_THREAD_LIMIT = 40;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||
const CODEX_REQUEST_TIMEOUT_MS = 4_500;
|
||||
const CODEX_SOURCE_TIMEOUT_MS = 5_200;
|
||||
const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150;
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
|
|
@ -58,18 +57,11 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
return [];
|
||||
}
|
||||
|
||||
const liveThreads = await this.#listThreadsSegmentSafe(binaryPath, 'live', {
|
||||
archived: false,
|
||||
totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||
});
|
||||
const archivedPromise = this.#listThreadsSegmentSafe(binaryPath, 'archived', {
|
||||
archived: true,
|
||||
totalTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
|
||||
});
|
||||
const archivedThreads =
|
||||
liveThreads.length > 0
|
||||
? await this.#awaitWithTimeout(archivedPromise, FAST_ARCHIVED_MERGE_TIMEOUT_MS)
|
||||
: await archivedPromise;
|
||||
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||
this.#logSegmentFailure(threadSegments, 'live');
|
||||
this.#logSegmentFailure(threadSegments, 'archived');
|
||||
const liveThreads = threadSegments.live.threads;
|
||||
const archivedThreads = threadSegments.archived.threads;
|
||||
|
||||
const interactiveThreads = [...liveThreads, ...archivedThreads].filter(
|
||||
(thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source)
|
||||
|
|
@ -86,67 +78,45 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
return candidates;
|
||||
}
|
||||
|
||||
async #listThreadsSegment(
|
||||
binaryPath: string,
|
||||
segment: 'live' | 'archived',
|
||||
options: {
|
||||
archived: boolean;
|
||||
totalTimeoutMs: number;
|
||||
}
|
||||
): Promise<CodexThreadSummary[]> {
|
||||
const result = await this.deps.appServerClient.listThreads(binaryPath, {
|
||||
archived: options.archived,
|
||||
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||
limit: CODEX_THREAD_LIMIT,
|
||||
requestTimeoutMs: CODEX_REQUEST_TIMEOUT_MS,
|
||||
totalTimeoutMs: options.totalTimeoutMs,
|
||||
liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||
archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
|
||||
totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
this.deps.logger.info('codex recent-projects thread list loaded', {
|
||||
segment,
|
||||
count: result.length,
|
||||
this.deps.logger.info('codex recent-projects thread lists loaded', {
|
||||
liveCount: result.live.threads.length,
|
||||
archivedCount: result.archived.threads.length,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async #awaitWithTimeout(
|
||||
promise: Promise<CodexThreadSummary[]>,
|
||||
timeoutMs: number
|
||||
): Promise<CodexThreadSummary[]> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<CodexThreadSummary[]>((resolve) => {
|
||||
timer = setTimeout(() => resolve([]), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
#logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void {
|
||||
const error = result[segment].error;
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#unwrapThreadListError(error: unknown, segment: 'live' | 'archived'): CodexThreadSummary[] {
|
||||
this.deps.logger.warn('codex recent-projects thread list failed', {
|
||||
segment,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
async #listThreadsSegmentSafe(
|
||||
binaryPath: string,
|
||||
segment: 'live' | 'archived',
|
||||
options: {
|
||||
archived: boolean;
|
||||
totalTimeoutMs: number;
|
||||
}
|
||||
): Promise<CodexThreadSummary[]> {
|
||||
async #listRecentThreadsSafe(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||
try {
|
||||
return await this.#listThreadsSegment(binaryPath, segment, options);
|
||||
return await this.#listRecentThreads(binaryPath);
|
||||
} catch (error) {
|
||||
return this.#unwrapThreadListError(error, segment);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.deps.logger.warn('codex recent-projects thread list session failed', {
|
||||
error: message,
|
||||
});
|
||||
return {
|
||||
live: { threads: [], error: message },
|
||||
archived: { threads: [], error: message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,26 +38,38 @@ export interface CodexThreadSummary {
|
|||
path?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexThreadSegmentResult {
|
||||
threads: CodexThreadSummary[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CodexRecentThreadsResult {
|
||||
live: CodexThreadSegmentResult;
|
||||
archived: CodexThreadSegmentResult;
|
||||
}
|
||||
|
||||
export class CodexAppServerClient {
|
||||
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
||||
|
||||
async listThreads(
|
||||
async listRecentThreads(
|
||||
binaryPath: string,
|
||||
options: {
|
||||
archived: boolean;
|
||||
limit: number;
|
||||
requestTimeoutMs?: number;
|
||||
liveRequestTimeoutMs?: number;
|
||||
archivedRequestTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
}
|
||||
): Promise<CodexThreadSummary[]> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
): Promise<CodexRecentThreadsResult> {
|
||||
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||
const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
|
||||
|
||||
return this.rpcClient.withSession(
|
||||
{
|
||||
binaryPath,
|
||||
args: ['app-server'],
|
||||
requestTimeoutMs,
|
||||
requestTimeoutMs: sessionRequestTimeoutMs,
|
||||
totalTimeoutMs,
|
||||
label: 'codex app-server thread/list',
|
||||
},
|
||||
|
|
@ -75,22 +87,51 @@ export class CodexAppServerClient {
|
|||
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
||||
},
|
||||
},
|
||||
requestTimeoutMs
|
||||
sessionRequestTimeoutMs
|
||||
);
|
||||
|
||||
await session.notify('initialized');
|
||||
|
||||
const response = await session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: options.archived,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
requestTimeoutMs
|
||||
);
|
||||
const [live, archived] = await Promise.allSettled([
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
liveRequestTimeoutMs
|
||||
),
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
archivedRequestTimeoutMs
|
||||
),
|
||||
]);
|
||||
|
||||
return response.data ?? [];
|
||||
return {
|
||||
live:
|
||||
live.status === 'fulfilled'
|
||||
? { threads: live.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
|
||||
},
|
||||
archived:
|
||||
archived.status === 'fulfilled'
|
||||
? { threads: archived.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error:
|
||||
archived.reason instanceof Error
|
||||
? archived.reason.message
|
||||
: String(archived.reason),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
||||
|
||||
import type { JsonRpcSession, JsonRpcStdioClient } from '@features/recent-projects/main/infrastructure/codex/JsonRpcStdioClient';
|
||||
|
||||
function createSession(
|
||||
request: JsonRpcSession['request'],
|
||||
notify: JsonRpcSession['notify'] = vi.fn().mockResolvedValue(undefined)
|
||||
): JsonRpcSession {
|
||||
return {
|
||||
request,
|
||||
notify,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexAppServerClient', () => {
|
||||
it('loads live and archived threads in a single app-server session', async () => {
|
||||
const session = createSession(
|
||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
if (method === 'initialize') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
if (method === 'thread/list' && params?.archived === false) {
|
||||
return Promise.resolve({
|
||||
data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'thread/list' && params?.archived === true) {
|
||||
return Promise.resolve({
|
||||
data: [{ id: 'archived-1', cwd: '/Users/test/archive-project', source: 'vscode' }],
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||
})
|
||||
);
|
||||
|
||||
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
||||
|
||||
const result = await client.listRecentThreads('/usr/local/bin/codex', {
|
||||
limit: 40,
|
||||
liveRequestTimeoutMs: 4500,
|
||||
archivedRequestTimeoutMs: 2500,
|
||||
totalTimeoutMs: 4500,
|
||||
});
|
||||
|
||||
expect(withSession).toHaveBeenCalledTimes(1);
|
||||
expect(withSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
requestTimeoutMs: 4500,
|
||||
totalTimeoutMs: 4500,
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(session.notify).toHaveBeenCalledWith('initialized');
|
||||
expect(result).toEqual({
|
||||
live: {
|
||||
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||
},
|
||||
archived: {
|
||||
threads: [{ id: 'archived-1', cwd: '/Users/test/archive-project', source: 'vscode' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps live results when archived thread loading fails', async () => {
|
||||
const session = createSession(
|
||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
if (method === 'initialize') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
if (method === 'thread/list' && params?.archived === false) {
|
||||
return Promise.resolve({
|
||||
data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'thread/list' && params?.archived === true) {
|
||||
return Promise.reject(new Error('JSON-RPC request timed out: thread/list'));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||
})
|
||||
);
|
||||
|
||||
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
||||
|
||||
const result = await client.listRecentThreads('/usr/local/bin/codex', {
|
||||
limit: 40,
|
||||
liveRequestTimeoutMs: 4500,
|
||||
archivedRequestTimeoutMs: 2500,
|
||||
totalTimeoutMs: 4500,
|
||||
});
|
||||
|
||||
expect(result.live.threads).toEqual([
|
||||
{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' },
|
||||
]);
|
||||
expect(result.archived).toEqual({
|
||||
threads: [],
|
||||
error: 'JSON-RPC request timed out: thread/list',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue