fix: harden recent project recovery and path matching

This commit is contained in:
777genius 2026-04-14 21:39:16 +03:00
parent 68378c603c
commit c3f18df062
8 changed files with 443 additions and 56 deletions

View file

@ -64,8 +64,11 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
const successful = results.flatMap((result) => result.candidates);
const hasDegradedSources = results.some((result) => result.degraded);
const response: ListDashboardRecentProjectsResponse = {
projects: mergeRecentProjectCandidates(successful),
};
if (hasDegradedSources && stale) {
if (hasDegradedSources && stale && response.projects.length === 0) {
await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs);
this.deps.logger.info('recent-projects served stale cache', {
cacheKey,
@ -76,9 +79,6 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
return stale;
}
const response: ListDashboardRecentProjectsResponse = {
projects: mergeRecentProjectCandidates(successful),
};
const viewModel = this.deps.output.present(response);
const cacheTtlMs = hasDegradedSources
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)

View file

@ -17,8 +17,10 @@ const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_ARCHIVED_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS + 1_500;
function isInteractiveSource(source: unknown): boolean {
return source === 'vscode' || source === 'cli';
@ -116,6 +118,37 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
this.deps.logger.warn('codex recent-projects thread list session failed', {
error: message,
});
if (message.toLowerCase().includes('timed out')) {
return {
live: { threads: [], error: message },
archived: { threads: [], error: message },
};
}
try {
const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, {
limit: CODEX_THREAD_LIMIT,
requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS,
totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS,
});
this.deps.logger.info('codex recent-projects recovered with live-only fallback', {
liveCount: liveFallback.threads.length,
});
return {
live: liveFallback,
archived: { threads: [], error: message },
};
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
this.deps.logger.warn('codex recent-projects live-only fallback failed', {
error: fallbackMessage,
});
}
return {
live: { threads: [], error: message },
archived: { threads: [], error: message },

View file

@ -1,4 +1,4 @@
import type { JsonRpcStdioClient } from './JsonRpcStdioClient';
import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient';
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
@ -49,9 +49,55 @@ export interface CodexRecentThreadsResult {
archived: CodexThreadSegmentResult;
}
interface ThreadListSessionOptions {
binaryPath: string;
requestTimeoutMs: number;
totalTimeoutMs: number;
label: string;
}
export class CodexAppServerClient {
constructor(private readonly rpcClient: JsonRpcStdioClient) {}
async listRecentLiveThreads(
binaryPath: string,
options: {
limit: number;
requestTimeoutMs?: number;
totalTimeoutMs?: number;
}
): Promise<CodexThreadSegmentResult> {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
);
return this.#withThreadListSession(
{
binaryPath,
requestTimeoutMs,
totalTimeoutMs,
label: 'codex app-server thread/list live',
},
async (session) => {
const live = await session.request<ThreadListResponse>(
'thread/list',
{
archived: false,
limit: options.limit,
sortKey: 'updated_at',
},
requestTimeoutMs
);
return {
threads: live.data ?? [],
};
}
);
}
async listRecentThreads(
binaryPath: string,
options: {
@ -66,36 +112,17 @@ export class CodexAppServerClient {
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
const totalTimeoutMs = Math.max(
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
liveRequestTimeoutMs + archivedRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
);
return this.rpcClient.withSession(
return this.#withThreadListSession(
{
binaryPath,
args: ['app-server'],
requestTimeoutMs: sessionRequestTimeoutMs,
totalTimeoutMs,
label: 'codex app-server thread/list',
},
async (session) => {
await session.request(
'initialize',
{
clientInfo: {
name: 'claude-agent-teams-ui',
title: 'Claude Agent Teams UI',
version: '0.1.0',
},
capabilities: {
experimentalApi: false,
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
},
},
sessionRequestTimeoutMs
);
await session.notify('initialized');
const [live, archived] = await Promise.allSettled([
session.request<ThreadListResponse>(
'thread/list',
@ -139,4 +166,39 @@ export class CodexAppServerClient {
}
);
}
async #withThreadListSession<T>(
options: ThreadListSessionOptions,
handler: (session: JsonRpcSession) => Promise<T>
): Promise<T> {
return this.rpcClient.withSession(
{
binaryPath: options.binaryPath,
args: ['app-server'],
requestTimeoutMs: options.requestTimeoutMs,
totalTimeoutMs: options.totalTimeoutMs,
label: options.label,
},
async (session) => {
await session.request(
'initialize',
{
clientInfo: {
name: 'claude-agent-teams-ui',
title: 'Claude Agent Teams UI',
version: '0.1.0',
},
capabilities: {
experimentalApi: false,
optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS,
},
},
options.requestTimeoutMs
);
await session.notify('initialized');
return handler(session);
}
);
}
}

View file

@ -1,5 +1,3 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
@ -22,11 +20,20 @@ function canUseLocalStorage(): boolean {
}
function normalizeHistoryPath(projectPath: string): string | null {
const trimmed = projectPath.trim();
if (!trimmed) {
let normalizedPath = projectPath.trim().replace(/\\/g, '/');
if (!normalizedPath) {
return null;
}
return normalizePath(trimmed);
if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) {
while (normalizedPath.endsWith('/')) {
normalizedPath = normalizedPath.slice(0, -1);
}
}
return normalizedPath;
}
function foldHistoryPath(projectPath: string): string {
return projectPath.toLowerCase();
}
function readHistoryState(): RecentProjectOpenHistoryState {
@ -99,16 +106,72 @@ function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]):
}
}
function createHistoryLookup(): Map<string, number> {
return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt]));
interface HistoryLookup {
exact: Map<string, number>;
folded: Map<
string,
{
openedAt: number;
exactPaths: Set<string>;
}
>;
}
function getProjectPaths(
function createHistoryLookup(): HistoryLookup {
const exact = new Map<string, number>();
const folded = new Map<string, { openedAt: number; exactPaths: Set<string> }>();
for (const entry of readHistoryState().entries) {
const normalizedPath = normalizeHistoryPath(entry.path);
if (!normalizedPath) {
continue;
}
exact.set(normalizedPath, Math.max(exact.get(normalizedPath) ?? 0, entry.openedAt));
const foldedKey = foldHistoryPath(normalizedPath);
const existingFolded = folded.get(foldedKey);
if (existingFolded) {
existingFolded.openedAt = Math.max(existingFolded.openedAt, entry.openedAt);
existingFolded.exactPaths.add(normalizedPath);
} else {
folded.set(foldedKey, {
openedAt: entry.openedAt,
exactPaths: new Set([normalizedPath]),
});
}
}
return { exact, folded };
}
function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): number {
const normalizedPath = normalizeHistoryPath(projectPath);
if (!normalizedPath) {
return 0;
}
const exactMatch = lookup.exact.get(normalizedPath);
if (exactMatch != null) {
return exactMatch;
}
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
return 0;
}
return foldedMatch.openedAt;
}
function getProjectLastOpenedAtFromLookup(
lookup: HistoryLookup,
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
): string[] {
return [project.primaryPath, ...project.associatedPaths]
.map((projectPath) => normalizeHistoryPath(projectPath))
.filter((projectPath): projectPath is string => Boolean(projectPath));
): number {
return [project.primaryPath, ...project.associatedPaths].reduce(
(latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)),
0
);
}
export function recordRecentProjectOpenPaths(
@ -141,10 +204,7 @@ export function getRecentProjectLastOpenedAt(
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
): number {
const historyLookup = createHistoryLookup();
return getProjectPaths(project).reduce(
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
0
);
return getProjectLastOpenedAtFromLookup(historyLookup, project);
}
export function sortRecentProjectsByDisplayPriority(
@ -153,20 +213,12 @@ export function sortRecentProjectsByDisplayPriority(
): DashboardRecentProject[] {
const historyLookup = createHistoryLookup();
const getLastOpenedAt = (
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
): number =>
getProjectPaths(project).reduce(
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
0
);
const isPriorityOpen = (openedAt: number): boolean =>
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
return [...projects].sort((left, right) => {
const leftOpenedAt = getLastOpenedAt(left);
const rightOpenedAt = getLastOpenedAt(right);
const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left);
const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right);
const leftPriority = isPriorityOpen(leftOpenedAt);
const rightPriority = isPriorityOpen(rightOpenedAt);

View file

@ -248,7 +248,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
}
});
it('returns stale cached data when a source degrades after cache expiry', async () => {
it('prefers fresh healthy-source results over stale cache when degraded sources still leave usable projects', async () => {
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(null),
@ -294,6 +294,72 @@ describe('ListDashboardRecentProjectsUseCase', () => {
logger,
});
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual({
ids: ['repo:fresh'],
sources: ['claude'],
});
expect(output.present).toHaveBeenCalledWith({
projects: [
expect.objectContaining({
identity: 'repo:fresh',
source: 'claude',
}),
],
});
expect(cache.set).toHaveBeenCalledWith(
'recent-projects:stale',
{ ids: ['repo:fresh'], sources: ['claude'] },
1_500
);
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
cacheKey: 'recent-projects:stale',
count: 1,
degradedSources: 1,
cacheTtlMs: 1_500,
durationMs: 200,
});
});
it('falls back to stale cache only when degraded sources leave no usable fresh projects', async () => {
const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] };
const cache: RecentProjectsCachePort<TestViewModel> = {
get: vi.fn().mockResolvedValue(null),
getStale: vi.fn().mockResolvedValue(stale),
set: vi.fn().mockResolvedValue(undefined),
};
const output: ListDashboardRecentProjectsOutputPort<TestViewModel> = {
present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({
ids: response.projects.map((project) => project.identity),
sources: response.projects.map((project) => project.source),
})),
};
const sources: RecentProjectsSourcePort[] = [
{
sourceId: 'claude',
list: vi.fn().mockResolvedValue([]),
},
{
sourceId: 'codex',
list: vi.fn().mockRejectedValue(new Error('codex unavailable')),
},
];
const logger = createLogger();
let now = 15_000;
const useCase = new ListDashboardRecentProjectsUseCase({
sources,
cache,
output,
clock: {
now: () => {
const current = now;
now += 200;
return current;
},
},
logger,
});
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
expect(output.present).not.toHaveBeenCalled();
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);

View file

@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
function createLogger(): LoggerPort & {
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
describe('CodexRecentProjectsSourceAdapter', () => {
it('falls back to live-only threads when the full app-server session fails fast', async () => {
const logger = createLogger();
const appServerClient = {
listRecentThreads: vi
.fn()
.mockRejectedValue(new Error('JSON-RPC process exited unexpectedly (code=1 signal=null)')),
listRecentLiveThreads: vi.fn().mockResolvedValue({
threads: [
{
id: 'thread-live',
cwd: '/Users/belief/dev/projects/headless',
source: 'cli',
updatedAt: 1_700_000_000,
gitInfo: { branch: 'main' },
},
],
}),
} as unknown as CodexAppServerClient;
const identityResolver = {
resolve: vi.fn().mockResolvedValue({
id: 'repo:headless',
name: 'headless',
}),
} as unknown as RecentProjectIdentityResolver;
const adapter = new CodexRecentProjectsSourceAdapter({
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
appServerClient,
identityResolver,
logger,
});
await expect(adapter.list()).resolves.toEqual([
expect.objectContaining({
identity: 'repo:headless',
displayName: 'headless',
primaryPath: '/Users/belief/dev/projects/headless',
providerIds: ['codex'],
sourceKind: 'codex',
openTarget: {
type: 'synthetic-path',
path: '/Users/belief/dev/projects/headless',
},
branchName: 'main',
}),
]);
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('codex recent-projects recovered with live-only fallback', {
liveCount: 1,
});
});
it('does not spend extra time on live-only fallback after a full session timeout', async () => {
const logger = createLogger();
const appServerClient = {
listRecentThreads: vi
.fn()
.mockRejectedValue(new Error('codex app-server thread/list timed out after 8500ms')),
listRecentLiveThreads: vi.fn(),
} as unknown as CodexAppServerClient;
const identityResolver = {
resolve: vi.fn(),
} as unknown as RecentProjectIdentityResolver;
const adapter = new CodexRecentProjectsSourceAdapter({
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
appServerClient,
identityResolver,
logger,
});
await expect(adapter.list()).resolves.toEqual([]);
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
});
});

View file

@ -53,7 +53,7 @@ describe('CodexAppServerClient', () => {
expect.objectContaining({
binaryPath: '/usr/local/bin/codex',
requestTimeoutMs: 4500,
totalTimeoutMs: 6000,
totalTimeoutMs: 8500,
}),
expect.any(Function)
);
@ -135,9 +135,49 @@ describe('CodexAppServerClient', () => {
expect(withSession).toHaveBeenCalledWith(
expect.objectContaining({
totalTimeoutMs: 6000,
totalTimeoutMs: 8500,
}),
expect.any(Function)
);
});
it('can load only live threads in a dedicated fallback 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' }],
});
}
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.listRecentLiveThreads('/usr/local/bin/codex', {
limit: 40,
requestTimeoutMs: 4500,
totalTimeoutMs: 6000,
});
expect(withSession).toHaveBeenCalledWith(
expect.objectContaining({
binaryPath: '/usr/local/bin/codex',
requestTimeoutMs: 4500,
totalTimeoutMs: 6000,
label: 'codex app-server thread/list live',
}),
expect.any(Function)
);
expect(result).toEqual({
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
});
});
});

View file

@ -98,4 +98,36 @@ describe('recentProjectOpenHistory', () => {
).map((project) => project.id)
).toEqual(['repo:active', 'repo:opened']);
});
it('does not collapse distinct case-variant paths when history contains ambiguous entries', () => {
recordRecentProjectOpenPaths(['/Work/Repo'], 5_000);
recordRecentProjectOpenPaths(['/work/repo'], 8_000);
expect(
getRecentProjectLastOpenedAt(
makeProject({
primaryPath: '/Work/Repo',
associatedPaths: ['/Work/Repo'],
})
)
).toBe(5_000);
expect(
getRecentProjectLastOpenedAt(
makeProject({
primaryPath: '/work/repo',
associatedPaths: ['/work/repo'],
})
)
).toBe(8_000);
expect(
getRecentProjectLastOpenedAt(
makeProject({
primaryPath: '/WORK/repo',
associatedPaths: ['/WORK/repo'],
})
)
).toBe(0);
});
});