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 { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate';
|
||||||
import type {
|
import type {
|
||||||
CodexAppServerClient,
|
CodexAppServerClient,
|
||||||
|
CodexRecentThreadsResult,
|
||||||
CodexThreadSummary,
|
CodexThreadSummary,
|
||||||
} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
} from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
|
||||||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
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_THREAD_LIMIT = 40;
|
||||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||||
const CODEX_REQUEST_TIMEOUT_MS = 4_500;
|
|
||||||
const CODEX_SOURCE_TIMEOUT_MS = 5_200;
|
const CODEX_SOURCE_TIMEOUT_MS = 5_200;
|
||||||
const FAST_ARCHIVED_MERGE_TIMEOUT_MS = 150;
|
|
||||||
|
|
||||||
function isInteractiveSource(source: unknown): boolean {
|
function isInteractiveSource(source: unknown): boolean {
|
||||||
return source === 'vscode' || source === 'cli';
|
return source === 'vscode' || source === 'cli';
|
||||||
|
|
@ -58,18 +57,11 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveThreads = await this.#listThreadsSegmentSafe(binaryPath, 'live', {
|
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||||
archived: false,
|
this.#logSegmentFailure(threadSegments, 'live');
|
||||||
totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
this.#logSegmentFailure(threadSegments, 'archived');
|
||||||
});
|
const liveThreads = threadSegments.live.threads;
|
||||||
const archivedPromise = this.#listThreadsSegmentSafe(binaryPath, 'archived', {
|
const archivedThreads = threadSegments.archived.threads;
|
||||||
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 interactiveThreads = [...liveThreads, ...archivedThreads].filter(
|
const interactiveThreads = [...liveThreads, ...archivedThreads].filter(
|
||||||
(thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source)
|
(thread) => Boolean(thread.cwd) && isInteractiveSource(thread.source)
|
||||||
|
|
@ -86,67 +78,45 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #listThreadsSegment(
|
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||||
binaryPath: string,
|
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||||
segment: 'live' | 'archived',
|
|
||||||
options: {
|
|
||||||
archived: boolean;
|
|
||||||
totalTimeoutMs: number;
|
|
||||||
}
|
|
||||||
): Promise<CodexThreadSummary[]> {
|
|
||||||
const result = await this.deps.appServerClient.listThreads(binaryPath, {
|
|
||||||
archived: options.archived,
|
|
||||||
limit: CODEX_THREAD_LIMIT,
|
limit: CODEX_THREAD_LIMIT,
|
||||||
requestTimeoutMs: CODEX_REQUEST_TIMEOUT_MS,
|
liveRequestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||||
totalTimeoutMs: options.totalTimeoutMs,
|
archivedRequestTimeoutMs: CODEX_ARCHIVED_FETCH_TIMEOUT_MS,
|
||||||
|
totalTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.deps.logger.info('codex recent-projects thread list loaded', {
|
this.deps.logger.info('codex recent-projects thread lists loaded', {
|
||||||
segment,
|
liveCount: result.live.threads.length,
|
||||||
count: result.length,
|
archivedCount: result.archived.threads.length,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #awaitWithTimeout(
|
#logSegmentFailure(result: CodexRecentThreadsResult, segment: 'live' | 'archived'): void {
|
||||||
promise: Promise<CodexThreadSummary[]>,
|
const error = result[segment].error;
|
||||||
timeoutMs: number
|
if (!error) {
|
||||||
): Promise<CodexThreadSummary[]> {
|
return;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#unwrapThreadListError(error: unknown, segment: 'live' | 'archived'): CodexThreadSummary[] {
|
|
||||||
this.deps.logger.warn('codex recent-projects thread list failed', {
|
this.deps.logger.warn('codex recent-projects thread list failed', {
|
||||||
segment,
|
segment,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error,
|
||||||
});
|
});
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #listThreadsSegmentSafe(
|
async #listRecentThreadsSafe(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||||
binaryPath: string,
|
|
||||||
segment: 'live' | 'archived',
|
|
||||||
options: {
|
|
||||||
archived: boolean;
|
|
||||||
totalTimeoutMs: number;
|
|
||||||
}
|
|
||||||
): Promise<CodexThreadSummary[]> {
|
|
||||||
try {
|
try {
|
||||||
return await this.#listThreadsSegment(binaryPath, segment, options);
|
return await this.#listRecentThreads(binaryPath);
|
||||||
} catch (error) {
|
} 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;
|
path?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodexThreadSegmentResult {
|
||||||
|
threads: CodexThreadSummary[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRecentThreadsResult {
|
||||||
|
live: CodexThreadSegmentResult;
|
||||||
|
archived: CodexThreadSegmentResult;
|
||||||
|
}
|
||||||
|
|
||||||
export class CodexAppServerClient {
|
export class CodexAppServerClient {
|
||||||
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
|
||||||
|
|
||||||
async listThreads(
|
async listRecentThreads(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
options: {
|
options: {
|
||||||
archived: boolean;
|
|
||||||
limit: number;
|
limit: number;
|
||||||
requestTimeoutMs?: number;
|
liveRequestTimeoutMs?: number;
|
||||||
|
archivedRequestTimeoutMs?: number;
|
||||||
totalTimeoutMs?: number;
|
totalTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
): Promise<CodexThreadSummary[]> {
|
): Promise<CodexRecentThreadsResult> {
|
||||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
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;
|
const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
|
||||||
|
|
||||||
return this.rpcClient.withSession(
|
return this.rpcClient.withSession(
|
||||||
{
|
{
|
||||||
binaryPath,
|
binaryPath,
|
||||||
args: ['app-server'],
|
args: ['app-server'],
|
||||||
requestTimeoutMs,
|
requestTimeoutMs: sessionRequestTimeoutMs,
|
||||||
totalTimeoutMs,
|
totalTimeoutMs,
|
||||||
label: 'codex app-server thread/list',
|
label: 'codex app-server thread/list',
|
||||||
},
|
},
|
||||||
|
|
@ -75,22 +87,51 @@ export class CodexAppServerClient {
|
||||||
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
requestTimeoutMs
|
sessionRequestTimeoutMs
|
||||||
);
|
);
|
||||||
|
|
||||||
await session.notify('initialized');
|
await session.notify('initialized');
|
||||||
|
|
||||||
const response = await session.request<ThreadListResponse>(
|
const [live, archived] = await Promise.allSettled([
|
||||||
'thread/list',
|
session.request<ThreadListResponse>(
|
||||||
{
|
'thread/list',
|
||||||
archived: options.archived,
|
{
|
||||||
limit: options.limit,
|
archived: false,
|
||||||
sortKey: 'updated_at',
|
limit: options.limit,
|
||||||
},
|
sortKey: 'updated_at',
|
||||||
requestTimeoutMs
|
},
|
||||||
);
|
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