diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 69a73ea9b92..a9653e55471 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -2630,7 +2630,7 @@ "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 170, "src/main/native-chat/transcript-watch.test.ts": 3388, "src/main/native-chat/transcript-window-tool-attribution.test.ts": 146, - "src/main/native-chat/wsl-codex-session-path-scan.test.ts": 100, + "src/main/native-chat/wsl-session-path-scan.test.ts": 100, "src/main/native-chat/wsl-transcript-fs-access.test.ts": 147, "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 538, "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 79, diff --git a/src/main/ai-vault/session-scanner-roots.ts b/src/main/ai-vault/session-scanner-roots.ts index 6963f2d9ce4..ea9bb419379 100644 --- a/src/main/ai-vault/session-scanner-roots.ts +++ b/src/main/ai-vault/session-scanner-roots.ts @@ -21,6 +21,8 @@ export const AGENT_SESSION_HOME_SEGMENTS = { omp: [['.omp', 'agent', 'sessions']] } as const satisfies Record +export type SessionHomeAgent = keyof typeof AGENT_SESSION_HOME_SEGMENTS + /** * This host's Claude projects roots, config-dir first. A structured Claude session * pins its account home to `CLAUDE_CONFIG_DIR || ~/.claude`, and the CLI writes @@ -59,7 +61,7 @@ export function claudeProjectsRootDirs(args: { /** Each WSL home's session roots for one agent, in table order. */ export function wslHomeSessionDirs( - agent: keyof typeof AGENT_SESSION_HOME_SEGMENTS, + agent: SessionHomeAgent, wslHomeDirs: readonly string[], joinUnderHome: (home: string, ...segments: string[]) => string = join ): string[] { diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index d6be1c624c8..3978bffdfe8 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -16,7 +16,7 @@ import { needsWslHostTranslation, resetHostReadableTranscriptPathCacheForTests, toHostReadableTranscriptPath, - wslCodexSessionsDirs + wslAgentSessionsDirs } from './host-readable-transcript-path' import { WslTranscriptFsError } from './wsl-transcript-fs-gate' @@ -262,7 +262,7 @@ describe('toHostReadableTranscriptPath', () => { listWslHomeDirs }) } - await wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs }) + await wslAgentSessionsDirs('codex', { platform: 'win32', listWslHomeDirs }) expect(listWslHomeDirs).toHaveBeenCalledTimes(1) }) @@ -312,16 +312,22 @@ describe('toHostReadableTranscriptPath', () => { }) }) -describe('wslCodexSessionsDirs', () => { +describe('wslAgentSessionsDirs', () => { it('returns nothing off Windows', async () => { await expect( - wslCodexSessionsDirs({ platform: 'darwin', listWslHomeDirs: async () => [UBUNTU_HOME] }) + wslAgentSessionsDirs('codex', { + platform: 'darwin', + listWslHomeDirs: async () => [UBUNTU_HOME] + }) ).resolves.toEqual([]) }) it('lists the managed and system Codex roots per distro home', async () => { await expect( - wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] }) + wslAgentSessionsDirs('codex', { + platform: 'win32', + listWslHomeDirs: async () => [UBUNTU_HOME] + }) ).resolves.toEqual([ `${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`, `${UBUNTU_HOME}\\.codex\\sessions` @@ -335,7 +341,10 @@ describe('wslCodexSessionsDirs', () => { }) await expect( - wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] }) + wslAgentSessionsDirs('codex', { + platform: 'win32', + listWslHomeDirs: async () => [UBUNTU_HOME] + }) ).resolves.toContain(`${accountHome}\\sessions`) }) @@ -346,7 +355,7 @@ describe('wslCodexSessionsDirs', () => { wslMocks.filterPathsToRunningWslDistrosAsync.mockResolvedValue([]) await expect( - wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [] }) + wslAgentSessionsDirs('codex', { platform: 'win32', listWslHomeDirs: async () => [] }) ).resolves.toEqual([]) }) }) diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index e6a3e9a8764..76ba09646b9 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -1,7 +1,11 @@ import { existsSync } from 'node:fs' import { isAbsolute } from 'node:path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' -import { wslHomeSessionDirs } from '../ai-vault/session-scanner-roots' +import { + uniqueDirs, + wslHomeSessionDirs, + type SessionHomeAgent +} from '../ai-vault/session-scanner-roots' import { getWslHomeAsync, listRunningWslDistrosAsync, listRunningWslHomeDirsAsync } from '../wsl' import { filterPathsToRunningWslDistrosAsync, @@ -260,18 +264,19 @@ function rankDistrosForGuestPath(wslHomeUncDirs: readonly string[], guestPath: s } /** - * WSL Codex sessions live under the guest home, not Windows AppData. Mirror AI - * Vault's dual-root discovery so the id-based resolve still finds them when the - * hook path is absent. + * Each running distro's session roots for one agent: they live under the guest + * home, not the Windows profile, so lookup by id needs them when there is no + * hook path. Same roots AI Vault discovery lists. */ -export async function wslCodexSessionsDirs( +export async function wslAgentSessionsDirs( + agent: SessionHomeAgent, deps: Pick = {} ): Promise { const platform = deps.platform ?? process.platform if (platform !== 'win32') { return [] } - const additionalHomes = getAdditionalCodexHomePaths?.() ?? [] + const additionalHomes = agent === 'codex' ? (getAdditionalCodexHomePaths?.() ?? []) : [] const [homeDirs, runningAdditionalHomes] = await Promise.all([ deps.wslSnapshot ? snapshotHomeDirs(deps.wslSnapshot) @@ -280,13 +285,13 @@ export async function wslCodexSessionsDirs( ? filterPathsToWslDistros(additionalHomes, deps.wslSnapshot.runningDistros) : filterPathsToRunningWslDistrosAsync(additionalHomes) ]) - const dirs = wslHomeSessionDirs('codex', homeDirs, joinUnderWslHome) + const dirs = wslHomeSessionDirs(agent, homeDirs, joinUnderWslHome) for (const home of runningAdditionalHomes) { if (parseWslUncPath(home)) { dirs.push(joinUnderWslHome(home, 'sessions')) } } - return dirs.filter((dir, index) => dirs.indexOf(dir) === index) + return uniqueDirs(dirs) } // Why: node:path.join is posix-flavoured off Windows and would mangle the diff --git a/src/main/native-chat/session-file-id-layouts.ts b/src/main/native-chat/session-file-id-layouts.ts new file mode 100644 index 00000000000..b0ad4ee4b89 --- /dev/null +++ b/src/main/native-chat/session-file-id-layouts.ts @@ -0,0 +1,46 @@ +import { win32 } from 'node:path' +import { GROK_CHAT_HISTORY_FILE } from '../../shared/grok-session-paths' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' + +export type SessionIdLookupAgent = 'claude' | 'codex' | 'grok' | 'omp' + +/** How one agent names a session's transcript file, for lookup by session id. */ +export type SessionFileIdLayout = { + fileMatchesId: (path: string, sessionId: string) => boolean + /** False skips a directory while looking for any id `wanted` accepts; depth 0 is a root child. */ + directoryPredicate?: (name: string, depth: number, wanted: (id: string) => boolean) => boolean +} + +// win32 path parsing splits on both separators, so host and `\\wsl.localhost` paths match alike. +function stem(path: string): string { + return win32.basename(path, win32.extname(path)) +} + +export const SESSION_FILE_ID_LAYOUTS: Record = { + // `//.jsonl` + claude: { fileMatchesId: (path, id) => win32.basename(path) === `${id}.jsonl` }, + // `rollout--.jsonl`, date-nested + codex: { + fileMatchesId: (path, id) => { + const name = stem(path) + return name === id || name.endsWith(`-${id}`) + } + }, + // `///chat_history.jsonl` + grok: { + fileMatchesId: (path, id) => + win32.basename(path) === GROK_CHAT_HISTORY_FILE && win32.basename(win32.dirname(path)) === id, + directoryPredicate: (name, depth, wanted) => depth === 0 || (depth === 1 && wanted(name)) + }, + // `//_.jsonl` + omp: { + fileMatchesId: (path, id) => { + const name = stem(path) + return name === id || name.endsWith(`_${id}`) + }, + // Why: a session's subagent transcripts live in its same-named `_/` + // artifact dir, and one can end in `_` too — so descending would let + // it win the suffix match over its own parent. Same prune as AI Vault discovery. + directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) + } +} diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index 360c3d8e556..362ff6c6c40 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -42,10 +42,21 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const HOST_ROLLOUT = 'C:\\host\\sessions\\rollout-wsl-sess.jsonl' -const scanned = vi.hoisted(() => ({ dirs: [] as string[], hostRootHasRollout: false })) +const scanned = vi.hoisted(() => { + const state: { dirs: string[]; hostRootHasRollout: boolean; wslHits: Record } = { + dirs: [], + hostRootHasRollout: false, + wslHits: {} + } + return state +}) vi.mock('../ai-vault/session-scanner-discovery', () => ({ walkSessionFiles: async (dir: string) => { scanned.dirs.push(dir) + const wslHit = scanned.wslHits[dir] + if (wslHit) { + return [wslHit] + } const isWslRoot = dir.startsWith('\\\\wsl.localhost\\') return scanned.hostRootHasRollout && !isWslRoot ? ['C:\\host\\sessions\\rollout-wsl-sess.jsonl'] @@ -55,7 +66,7 @@ vi.mock('../ai-vault/session-scanner-discovery', () => ({ import { resetHostReadableTranscriptPathCacheForTests } from './host-readable-transcript-path' import { resolveSessionFilePath } from './session-file-resolver' -import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import { getWslHomeAsync, listRunningWslHomeDirsAsync, listWslDistrosAsync } from '../wsl' const realPlatform = process.platform @@ -67,8 +78,10 @@ beforeEach(() => { resetHostReadableTranscriptPathCacheForTests() vi.mocked(getWslHomeAsync).mockClear() vi.mocked(listWslDistrosAsync).mockClear() + vi.mocked(listRunningWslHomeDirsAsync).mockClear() scanned.dirs = [] scanned.hostRootHasRollout = false + scanned.wslHits = {} READABLE_WSL_UNC_PATHS.clear() READABLE_WSL_UNC_PATHS.add(ROLLOUT_UNC) setPlatform('win32') @@ -184,4 +197,40 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => { }) expect(resolved).toBeNull() }) + + // #13789: Claude inside WSL with no hook path was searched only on the Windows profile. + it.each([ + ['claude', `${UBUNTU_HOME}\\.claude\\projects`, '-home-ada-repo\\wsl-sess.jsonl'], + ['omp', `${UBUNTU_HOME}\\.omp\\agent\\sessions`, '-home-ada-repo\\2026-07-24_wsl-sess.jsonl'], + [ + 'grok', + `${UBUNTU_HOME}\\.grok\\sessions`, + '%2Fhome%2Fada%2Frepo\\wsl-sess\\chat_history.jsonl' + ] + ] as const)( + 'finds a %s transcript that exists only in a running distro', + async (agent, root, relative) => { + const transcript = `${root}\\${relative}` + scanned.wslHits[root] = transcript + + await expect(resolveSessionFilePath(agent, 'wsl-sess')).resolves.toBe(transcript) + } + ) + + it('never searches the projects root of a distro that is not running', async () => { + vi.mocked(listRunningWslHomeDirsAsync).mockResolvedValueOnce([UBUNTU_HOME]) + + await expect(resolveSessionFilePath('claude', 'wsl-sess')).resolves.toBeNull() + + expect(scanned.dirs).toContain(`${UBUNTU_HOME}\\.claude\\projects`) + expect(scanned.dirs.some((dir) => dir.includes('Debian'))).toBe(false) + }) + + it('does not enumerate WSL distros when a host Claude root already has the session', async () => { + scanned.hostRootHasRollout = true + + await expect(resolveSessionFilePath('claude', 'wsl-sess')).resolves.toBe(HOST_ROLLOUT) + + expect(vi.mocked(listRunningWslHomeDirsAsync)).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 4d22ce7cd47..fd547e31948 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,6 +1,6 @@ import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { homedir } from 'node:os' -import { basename, extname, join } from 'node:path' +import { extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent, @@ -8,7 +8,6 @@ import { } from '../../shared/native-chat-agent-support' import { isWslUncPath } from '../../shared/wsl-paths' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' -import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' import { resolveOmpSessionsDir } from '../ai-vault/omp-session-root' import { claudeProjectsHostDirs, @@ -23,9 +22,10 @@ import { import { needsWslHostResolution, toHostReadableTranscriptPath, - wslCodexSessionsDirs + wslAgentSessionsDirs } from './host-readable-transcript-path' -import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' +import { SESSION_FILE_ID_LAYOUTS, type SessionIdLookupAgent } from './session-file-id-layouts' +import { findWslSessionPath } from './wsl-session-path-scan' import { parseSshTranscriptPath } from './ssh-transcript-path' import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' @@ -157,35 +157,37 @@ async function resolveSessionFileById( return null } + // An explicit root is the caller naming the exact tree its session pinned; adding + // a fallback there (another root or a distro) could resolve a different account's + // transcript, so overrides get no WSL tier. if (transcriptAgent === 'claude') { - // An explicit root is the caller naming the exact account tree its session pinned; - // adding a fallback there could resolve a different account's transcript. - return resolveClaudeSessionFile( - trimmedId, - options.claudeProjectsDir ? [options.claudeProjectsDir] : claudeProjectsDirs(), + const override = options.claudeProjectsDir + return resolveTiered('claude', trimmedId, override ? [override] : claudeProjectsDirs(), { + wslTier: !override, signal - ) + }) } if (transcriptAgent === 'codex') { - const overrideDirs = options.codexSessionsDirs - return resolveCodexSessionFile( - trimmedId, - overrideDirs ?? codexSessionsDirs(), - // Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the - // user left stopped. Only pay that after this host's own Codex roots miss. - overrideDirs ? undefined : wslCodexSessionsDirs, + const override = options.codexSessionsDirs + return resolveTiered('codex', trimmedId, override ?? codexSessionsDirs(), { + wslTier: !override, signal - ) + }) } if (transcriptAgent === 'grok') { - return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal) + const override = options.grokSessionsDir + return resolveTiered('grok', trimmedId, [override ?? grokSessionsDir()], { + wslTier: !override, + signal + }) } if (transcriptAgent === 'omp') { - return resolveOmpSessionFile( - trimmedId, - resolveOmpSessionsDir({ sessionsDir: options.ompSessionsDir }), + const override = options.ompSessionsDir + const root = resolveOmpSessionsDir({ sessionsDir: override }) + return resolveTiered('omp', trimmedId, root ? [root] : [], { + wslTier: override === undefined, signal - ) + }) } // Why: a new transcript agent must pick its own resolver. Falling through to // OMP's scan would search the wrong root with a foreign session id, so fail @@ -194,79 +196,43 @@ async function resolveSessionFileById( return null } -async function resolveClaudeSessionFile( +/** + * Search this host's roots in order (an earlier root wins), then — only on a miss — + * each running WSL distro's roots for the same agent. + */ +async function resolveTiered( + agent: SessionIdLookupAgent, sessionId: string, - projectsDirs: readonly string[], - signal?: AbortSignal + hostDirs: readonly string[], + options: { wslTier: boolean; signal?: AbortSignal } ): Promise { - const targetName = `${sessionId}.jsonl` - for (const projectsDir of projectsDirs) { - // No existence pre-check: walkSessionFiles already yields [] for a missing root. - const files = await walkSessionFiles(projectsDir, 'claude', [], { - extensions: new Set(['.jsonl']), - filePredicate: (path) => basename(path) === targetName, - signal - }) - if (files[0]) { - return files[0] - } - } - return null -} - -async function resolveCodexSessionFile( - sessionId: string, - sessionsDirs: string[], - loadFallbackDirs?: () => Promise, - signal?: AbortSignal -): Promise { - // Codex rollout file names embed the session id (rollout--.jsonl), so - // match the id as a suffix of the file's base name rather than an exact name. - // Search each candidate root (managed home first) and stop at the first match. - const hit = await findCodexRolloutInDirs(sessionId, sessionsDirs, signal) - if (hit) { + const { signal } = options + const hit = await findFirstInDirs(agent, sessionId, hostDirs, signal) + if (hit || !options.wslTier) { return hit } - if (!loadFallbackDirs) { - return null - } signal?.throwIfAborted() - // Why: a WSL-hosted session's rollout only exists inside the distro, so fall - // back to each distro's Codex homes when the host's own roots came up empty (#10326). - const fallbackDirs = (await loadFallbackDirs()).filter((dir) => !sessionsDirs.includes(dir)) + // Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the + // user left stopped. Only pay that after this host's own roots miss (#10326, #13789). + const wslDirs = (await wslAgentSessionsDirs(agent)).filter((dir) => !hostDirs.includes(dir)) signal?.throwIfAborted() - return findCodexRolloutInDirs(sessionId, fallbackDirs, signal) + return findFirstInDirs(agent, sessionId, wslDirs, signal) } -async function findCodexRolloutInDirs( +async function findFirstInDirs( + agent: SessionIdLookupAgent, sessionId: string, - sessionsDirs: string[], + dirs: readonly string[], signal?: AbortSignal ): Promise { let unavailable: WslTranscriptFsError | undefined - for (const sessionsDir of sessionsDirs) { - // Why: no existence pre-check — walkSessionFiles already yields [] for a - // missing/unreadable root, and a sync probe would block the main thread on a - // `\\wsl.localhost` root whose distro is stopped. - const scanOptions = { - extensions: new Set(['.jsonl']), - filePredicate: (path: string): boolean => { - const name = basename(path, extname(path)) - return name === sessionId || name.endsWith(`-${sessionId}`) - } - } - const isWslRoot = isWslUncPath(sessionsDir) + for (const dir of dirs) { try { - const files = isWslRoot - ? await findWslCodexSessionPath(sessionsDir, sessionId, signal) - : ( - await walkSessionFiles(sessionsDir, 'codex', [], { - ...scanOptions, - signal - }) - )[0] - if (files) { - return files + const hit = isWslUncPath(dir) + ? await findWslSessionPath(agent, dir, sessionId, signal) + : await findHostSessionFile(agent, dir, sessionId, signal) + if (hit) { + return hit } } catch (error) { // A caller abort that races the refusal stays authoritative. @@ -282,46 +248,30 @@ async function findCodexRolloutInDirs( return null } -async function resolveGrokSessionFile( +async function findHostSessionFile( + agent: SessionIdLookupAgent, + root: string, sessionId: string, - sessionsDir: string, signal?: AbortSignal ): Promise { - // Why: Native Chat runs on the main thread; use the bounded async direct-layout - // lookup instead of blocking, then repeating, a recursive full-tree scan. - signal?.throwIfAborted() - const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) - signal?.throwIfAborted() - return history -} - -// omp keeps one directory per working directory (`-Documents-dog-app`) with the -// transcript inside it, named `_.jsonl` — so match the -// id as a base-name suffix, the way Codex rollout files are matched, and let the -// walk cover the per-cwd subdirectories. -async function resolveOmpSessionFile( - sessionId: string, - sessionsDir: string, - signal?: AbortSignal -): Promise { - if (!sessionsDir) { - return null + if (agent === 'grok') { + // Why: Native Chat runs on the main thread; use the bounded async direct-layout + // lookup instead of a recursive full-tree scan. + signal?.throwIfAborted() + const history = await findGrokChatHistoryBySessionId(root, sessionId) + signal?.throwIfAborted() + return history } - const files = await walkSessionFiles(sessionsDir, 'omp', [], { + const { fileMatchesId, directoryPredicate } = SESSION_FILE_ID_LAYOUTS[agent] + const wanted = (id: string): boolean => id === sessionId + // No existence pre-check: the walk yields [] for a missing root, and a sync + // probe would block the main thread. + const files = await walkSessionFiles(root, agent, [], { extensions: new Set(['.jsonl']), - // Why: a session's task-subagent transcripts live in its same-named - // `_/` artifact dir, and a label-named child can still end in - // `_` — so descending would let a subagent transcript win the - // suffix match over its own parent. Prune the subtree exactly as the AI - // Vault scanner does (session-scanner-source-discovery.ts): it keeps the - // walk at one readdir per workspace dir regardless of how much the session - // delegated. Depth 0 is the workspace dir, which is never an artifact dir. - directoryPredicate: (name, depth) => - depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name), - filePredicate: (path) => { - const name = basename(path, extname(path)) - return name === sessionId || name.endsWith(`_${sessionId}`) - }, + filePredicate: (path) => fileMatchesId(path, sessionId), + directoryPredicate: directoryPredicate + ? (name, depth) => directoryPredicate(name, depth, wanted) + : undefined, signal }) return files[0] ?? null diff --git a/src/main/native-chat/wsl-codex-session-path-scan.test.ts b/src/main/native-chat/wsl-session-path-scan.test.ts similarity index 84% rename from src/main/native-chat/wsl-codex-session-path-scan.test.ts rename to src/main/native-chat/wsl-session-path-scan.test.ts index 7ea4c467501..3c1804e1e39 100644 --- a/src/main/native-chat/wsl-codex-session-path-scan.test.ts +++ b/src/main/native-chat/wsl-session-path-scan.test.ts @@ -6,7 +6,7 @@ vi.mock('../ai-vault/session-scanner-discovery', () => ({ walkSessionFiles: mocks.walk })) -import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' +import { findWslSessionPath } from './wsl-session-path-scan' function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -17,7 +17,7 @@ beforeEach(() => { mocks.walk.mockReset() }) -describe('WSL Codex session path scans', () => { +describe('WSL session path scans', () => { it('shares one root snapshot across concurrent session ids', async () => { const scan = deferred() let filePredicate: ((path: string) => boolean) | undefined @@ -33,8 +33,8 @@ describe('WSL Codex session path scans', () => { } ) - const first = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'first') - const second = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'second') + const first = findWslSessionPath('codex', '\\\\wsl.localhost\\Ubuntu\\sessions', 'first') + const second = findWslSessionPath('codex', '\\\\wsl.localhost\\Ubuntu\\sessions', 'second') const candidates = [ '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-first.jsonl', '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-second.jsonl', @@ -56,8 +56,16 @@ describe('WSL Codex session path scans', () => { .mockReturnValueOnce(initial.promise) .mockResolvedValueOnce(['\\\\wsl.localhost\\Ubuntu\\sessions\\2026\\rollout-created.jsonl']) - const initialCaller = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'absent') - const laterCaller = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'created') + const initialCaller = findWslSessionPath( + 'codex', + '\\\\wsl.localhost\\Ubuntu\\sessions', + 'absent' + ) + const laterCaller = findWslSessionPath( + 'codex', + '\\\\wsl.localhost\\Ubuntu\\sessions', + 'created' + ) initial.resolve([]) await expect(initialCaller).resolves.toBeNull() @@ -80,7 +88,8 @@ describe('WSL Codex session path scans', () => { } ) const controller = new AbortController() - const scan = findWslCodexSessionPath( + const scan = findWslSessionPath( + 'codex', '\\\\wsl.localhost\\Ubuntu\\sessions', 'closed', controller.signal @@ -98,7 +107,8 @@ describe('WSL Codex session path scans', () => { const replacementPath = '\\\\wsl.localhost\\Ubuntu\\sessions\\rollout-replacement.jsonl' mocks.walk.mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(replacementScan.promise) const controller = new AbortController() - const first = findWslCodexSessionPath( + const first = findWslSessionPath( + 'codex', '\\\\wsl.localhost\\Ubuntu\\sessions', 'first', controller.signal @@ -107,7 +117,8 @@ describe('WSL Codex session path scans', () => { controller.abort(new Error('closed')) await expect(first).rejects.toThrow('closed') - const replacement = findWslCodexSessionPath( + const replacement = findWslSessionPath( + 'codex', '\\\\wsl.localhost\\Ubuntu\\sessions', 'replacement' ) @@ -116,7 +127,11 @@ describe('WSL Codex session path scans', () => { abandoned.resolve([]) await Promise.resolve() await Promise.resolve() - const duplicate = findWslCodexSessionPath('\\\\wsl.localhost\\Ubuntu\\sessions', 'replacement') + const duplicate = findWslSessionPath( + 'codex', + '\\\\wsl.localhost\\Ubuntu\\sessions', + 'replacement' + ) replacementScan.resolve([replacementPath]) await expect(Promise.all([replacement, duplicate])).resolves.toEqual([ @@ -143,16 +158,16 @@ describe('WSL Codex session path scans', () => { ) const root = '\\\\wsl.localhost\\Ubuntu\\sessions' const keeperPath = `${root}\\rollout-keeper.jsonl` - const keeper = findWslCodexSessionPath(root, 'keeper') + const keeper = findWslSessionPath('codex', root, 'keeper') const initialThenCount = scanThen.mock.calls.length const duplicateController = new AbortController() - const duplicate = findWslCodexSessionPath(root, 'keeper', duplicateController.signal) + const duplicate = findWslSessionPath('codex', root, 'keeper', duplicateController.signal) const uniqueCanceled = Array.from({ length: 256 }, (_, index) => { const controller = new AbortController() return { controller, path: `${root}\\rollout-canceled-${index}.jsonl`, - promise: findWslCodexSessionPath(root, `canceled-${index}`, controller.signal) + promise: findWslSessionPath('codex', root, `canceled-${index}`, controller.signal) } }) const canceledResults = Promise.allSettled([ diff --git a/src/main/native-chat/wsl-codex-session-path-scan.ts b/src/main/native-chat/wsl-session-path-scan.ts similarity index 63% rename from src/main/native-chat/wsl-codex-session-path-scan.ts rename to src/main/native-chat/wsl-session-path-scan.ts index fbffa297ee8..73d25dbb3ab 100644 --- a/src/main/native-chat/wsl-codex-session-path-scan.ts +++ b/src/main/native-chat/wsl-session-path-scan.ts @@ -1,6 +1,6 @@ import type { Dirent } from 'node:fs' -import { basename, extname } from 'node:path' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { SESSION_FILE_ID_LAYOUTS, type SessionIdLookupAgent } from './session-file-id-layouts' import { wslGatedReaddir } from './wsl-transcript-fs-access' type ScanWaiter = { @@ -12,6 +12,8 @@ type ScanWaiter = { } type ScanGeneration = { + key: string + agent: SessionIdLookupAgent root: string controller: AbortController sessionIdRefCounts: Map @@ -25,39 +27,33 @@ function readDirectory(dirPath: string, signal: AbortSignal): Promise return wslGatedReaddir(dirPath, 'scan', signal) } -function sessionFileName(path: string): string { - return basename(path, extname(path)) -} - -function nameMatchesSessionId(name: string, sessionId: string): boolean { - return name === sessionId || name.endsWith(`-${sessionId}`) -} - -function matchesRequestedSession(path: string, sessionIds: Map): boolean { - const name = sessionFileName(path) - for (const sessionId of sessionIds.keys()) { - if (nameMatchesSessionId(name, sessionId)) { +function matchesRequestedSession(scan: ScanGeneration, path: string): boolean { + const { fileMatchesId } = SESSION_FILE_ID_LAYOUTS[scan.agent] + for (const sessionId of scan.sessionIdRefCounts.keys()) { + if (fileMatchesId(path, sessionId)) { return true } } return false } -function createScan(root: string): ScanGeneration { +function createScan(key: string, agent: SessionIdLookupAgent, root: string): ScanGeneration { const scan: ScanGeneration = { + key, + agent, root, controller: new AbortController(), sessionIdRefCounts: new Map(), waiters: new Set(), settled: false } - inFlightScans.set(root, scan) + inFlightScans.set(key, scan) return scan } function clearScan(scan: ScanGeneration): void { - if (inFlightScans.get(scan.root) === scan) { - inFlightScans.delete(scan.root) + if (inFlightScans.get(scan.key) === scan) { + inFlightScans.delete(scan.key) } } @@ -95,9 +91,14 @@ function settleScan(scan: ScanGeneration, outcome: { paths: string[] } | { error function startScan(scan: ScanGeneration): void { try { - const promise = walkSessionFiles(scan.root, 'codex', [], { + const { directoryPredicate } = SESSION_FILE_ID_LAYOUTS[scan.agent] + const wanted = (id: string): boolean => scan.sessionIdRefCounts.has(id) + const promise = walkSessionFiles(scan.root, scan.agent, [], { extensions: new Set(['.jsonl']), - filePredicate: (path) => matchesRequestedSession(path, scan.sessionIdRefCounts), + filePredicate: (path) => matchesRequestedSession(scan, path), + directoryPredicate: directoryPredicate + ? (name, depth) => directoryPredicate(name, depth, wanted) + : undefined, readDirectory: (dirPath) => readDirectory(dirPath, scan.controller.signal), signal: scan.controller.signal }) @@ -127,7 +128,7 @@ function waitForScan( if (!removeWaiter(scan, waiter)) { return } - reject(signal.reason ?? new Error('Codex session scan aborted')) + reject(signal.reason ?? new Error('WSL session scan aborted')) if (!scan.settled && scan.waiters.size === 0) { scan.settled = true clearScan(scan) @@ -142,13 +143,15 @@ function waitForScan( } async function scanRoot( + agent: SessionIdLookupAgent, root: string, sessionId: string, signal?: AbortSignal ): Promise<{ paths: string[]; joined: boolean }> { signal?.throwIfAborted() - const existing = inFlightScans.get(root) - const scan = existing ?? createScan(root) + const key = `${agent}\0${root}` + const existing = inFlightScans.get(key) + const scan = existing ?? createScan(key, agent, root) const pending = waitForScan(scan, sessionId, signal) if (!existing) { startScan(scan) @@ -156,21 +159,30 @@ async function scanRoot( return { paths: await pending, joined: Boolean(existing) } } -function findSessionPath(paths: string[], sessionId: string): string | null { - return paths.find((path) => nameMatchesSessionId(sessionFileName(path), sessionId)) ?? null +function findSessionPath( + agent: SessionIdLookupAgent, + paths: string[], + sessionId: string +): string | null { + const { fileMatchesId } = SESSION_FILE_ID_LAYOUTS[agent] + return paths.find((path) => fileMatchesId(path, sessionId)) ?? null } -/** Share tree discovery, then refresh a shared miss for post-start file creation. */ -export async function findWslCodexSessionPath( +/** + * Find one session's transcript under a `\\wsl.localhost` root. Concurrent lookups + * share one tree walk per root; a shared miss is refreshed for post-start file creation. + */ +export async function findWslSessionPath( + agent: SessionIdLookupAgent, root: string, sessionId: string, signal?: AbortSignal ): Promise { - const first = await scanRoot(root, sessionId, signal) - const firstHit = findSessionPath(first.paths, sessionId) + const first = await scanRoot(agent, root, sessionId, signal) + const firstHit = findSessionPath(agent, first.paths, sessionId) if (firstHit || !first.joined) { return firstHit } - const refreshed = await scanRoot(root, sessionId, signal) - return findSessionPath(refreshed.paths, sessionId) + const refreshed = await scanRoot(agent, root, sessionId, signal) + return findSessionPath(agent, refreshed.paths, sessionId) }