mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -21,6 +21,8 @@ export const AGENT_SESSION_HOME_SEGMENTS = {
|
||||
omp: [['.omp', 'agent', 'sessions']]
|
||||
} as const satisfies Record<string, readonly (readonly string[])[]>
|
||||
|
||||
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[] {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<HostReadableTranscriptPathDeps, 'platform' | 'listWslHomeDirs' | 'wslSnapshot'> = {}
|
||||
): Promise<string[]> {
|
||||
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
|
||||
|
||||
@@ -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<SessionIdLookupAgent, SessionFileIdLayout> = {
|
||||
// `<projects>/<slug>/<id>.jsonl`
|
||||
claude: { fileMatchesId: (path, id) => win32.basename(path) === `${id}.jsonl` },
|
||||
// `rollout-<ts>-<id>.jsonl`, date-nested
|
||||
codex: {
|
||||
fileMatchesId: (path, id) => {
|
||||
const name = stem(path)
|
||||
return name === id || name.endsWith(`-${id}`)
|
||||
}
|
||||
},
|
||||
// `<sessions>/<cwd group>/<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))
|
||||
},
|
||||
// `<sessions>/<cwd dir>/<ISO stamp>_<id>.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 `<stamp>_<uuid>/`
|
||||
// artifact dir, and one can end in `_<session id>` 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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> } = {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string[]>,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
// Codex rollout file names embed the session id (rollout-<ts>-<id>.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<string | null> {
|
||||
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<string | null> {
|
||||
// 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 `<ISO timestamp>_<session id>.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<string | null> {
|
||||
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
|
||||
// `<stamp>_<uuid>/` artifact dir, and a label-named child can still end in
|
||||
// `_<session id>` — 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
|
||||
|
||||
+28
-13
@@ -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<T>(): { promise: Promise<T>; 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<string[]>()
|
||||
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([
|
||||
+42
-30
@@ -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<string, number>
|
||||
@@ -25,39 +27,33 @@ function readDirectory(dirPath: string, signal: AbortSignal): Promise<Dirent[]>
|
||||
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<string, number>): 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<string | null> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user