From c9b7244bb90a5da139a2841291c70c1418f3dcbb Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 9 Oct 2026 19:27:30 -0700 Subject: [PATCH] fix(native-chat): lazy WSL tier for Claude, Grok and omp lookup by id (#27082) Only Codex had a WSL fallback when a transcript is looked up by session id, so Claude running inside WSL with no hook path was searched only on the Windows profile and chat showed "Transcript unavailable". Every id lookup now searches this host's roots first and, only on a miss, each running distro's roots for the same agent, through the shared per-root WSL scan. One layout table names how each agent files a session (claude, codex, grok, omp) for both host and WSL lookups. Co-authored-by: m4air --- config/scripts/ci-shard-timings.json | 2 +- src/main/ai-vault/session-scanner-roots.ts | 4 +- .../host-readable-transcript-path.test.ts | 23 ++- .../host-readable-transcript-path.ts | 21 +- .../native-chat/session-file-id-layouts.ts | 46 +++++ .../session-file-resolver-wsl.test.ts | 53 ++++- src/main/native-chat/session-file-resolver.ts | 186 +++++++----------- ....test.ts => wsl-session-path-scan.test.ts} | 41 ++-- ...-path-scan.ts => wsl-session-path-scan.ts} | 72 ++++--- 9 files changed, 268 insertions(+), 180 deletions(-) create mode 100644 src/main/native-chat/session-file-id-layouts.ts rename src/main/native-chat/{wsl-codex-session-path-scan.test.ts => wsl-session-path-scan.test.ts} (84%) rename src/main/native-chat/{wsl-codex-session-path-scan.ts => wsl-session-path-scan.ts} (63%) 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) }