mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(native-chat): stop the WSL transcript probe firing every poll tick
Three follow-ups from review of the WSL Codex transcript fix: - The UNC translation was retried on every fast resolve-poll tick (measured 10 sync UNC stats per 100ms) because only a successful result was memoized. Gate the retry to the slow fallback cadence. - A non-empty WSL home list was cached for the process lifetime, so a distro that was still booting during the first probe stayed excluded forever. Expire both branches; getWslHomeAsync caches successes, so a refresh only re-spawns wsl.exe for the distros that actually failed. - codexSessionsDirs enumerated every distro's home eagerly, waking distros the user left stopped even for native-Windows panes. Make the WSL roots a lazy tier consulted only after the host's own Codex roots miss. The resolve-poll suite became platform-dependent and only passed off Windows; pin the platform and add explicit win32 coverage.
This commit is contained in:
@@ -135,6 +135,37 @@ describe('toHostReadableTranscriptPath', () => {
|
||||
await wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs })
|
||||
expect(listWslHomeDirs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-probes after the TTL so a distro that was booting is not excluded forever', async () => {
|
||||
// Why: getWslHomeAsync returns null for a distro whose 5s $HOME probe timed
|
||||
// out on a cold boot. Latching that partial list for the process lifetime
|
||||
// would leave that distro's transcripts permanently unresolvable (#10326).
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const listWslHomeDirs = vi
|
||||
.fn<() => Promise<string[]>>()
|
||||
.mockResolvedValueOnce([UBUNTU_HOME])
|
||||
.mockResolvedValue([UBUNTU_HOME, DEBIAN_HOME])
|
||||
|
||||
const debianRollout = '\\\\wsl.localhost\\Debian\\home\\other\\x.jsonl'
|
||||
const call = (): Promise<string | null> =>
|
||||
toHostReadableTranscriptPath('/home/other/x.jsonl', {
|
||||
platform: 'win32',
|
||||
pathExists: (candidate) => candidate === debianRollout,
|
||||
listWslHomeDirs
|
||||
})
|
||||
|
||||
await expect(call()).resolves.toBeNull()
|
||||
await expect(call()).resolves.toBeNull()
|
||||
expect(listWslHomeDirs).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(Date.now() + 6 * 60_000)
|
||||
await expect(call()).resolves.toBe(debianRollout)
|
||||
expect(listWslHomeDirs).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('wslCodexSessionsDirs', () => {
|
||||
|
||||
@@ -39,6 +39,11 @@ export type HostReadableTranscriptPathDeps = {
|
||||
// caches, but getWslHomeAsync does NOT cache failures, so a cold/stopped distro
|
||||
// would re-spawn wsl.exe on every tick. Cache the composed answer here instead.
|
||||
const WSL_HOME_DIRS_EMPTY_RETRY_MS = 30_000
|
||||
// Why: a distro that was booting when we first probed resolves to no $HOME and
|
||||
// would otherwise be excluded for the whole session. Both branches expire so it
|
||||
// is retried; getWslHomeAsync caches successes, so a refresh only re-spawns
|
||||
// wsl.exe for the distros that actually failed.
|
||||
const WSL_HOME_DIRS_TTL_MS = 5 * 60_000
|
||||
let cachedWslHomeDirs: string[] | null = null
|
||||
let cachedWslHomeDirsExpiresAt = 0
|
||||
let inflightWslHomeDirs: Promise<string[]> | null = null
|
||||
@@ -51,10 +56,7 @@ async function defaultListWslHomeDirs(): Promise<string[]> {
|
||||
}
|
||||
|
||||
async function wslHomeDirs(load: () => Promise<string[]>): Promise<string[]> {
|
||||
if (
|
||||
cachedWslHomeDirs &&
|
||||
(cachedWslHomeDirs.length > 0 || Date.now() < cachedWslHomeDirsExpiresAt)
|
||||
) {
|
||||
if (cachedWslHomeDirs && Date.now() < cachedWslHomeDirsExpiresAt) {
|
||||
return cachedWslHomeDirs
|
||||
}
|
||||
if (inflightWslHomeDirs) {
|
||||
@@ -64,7 +66,8 @@ async function wslHomeDirs(load: () => Promise<string[]>): Promise<string[]> {
|
||||
.catch(() => [] as string[])
|
||||
.then((dirs) => {
|
||||
cachedWslHomeDirs = dirs
|
||||
cachedWslHomeDirsExpiresAt = Date.now() + WSL_HOME_DIRS_EMPTY_RETRY_MS
|
||||
cachedWslHomeDirsExpiresAt =
|
||||
Date.now() + (dirs.length > 0 ? WSL_HOME_DIRS_TTL_MS : WSL_HOME_DIRS_EMPTY_RETRY_MS)
|
||||
inflightWslHomeDirs = null
|
||||
return dirs
|
||||
})
|
||||
|
||||
@@ -15,24 +15,31 @@ vi.mock('../wsl', () => ({
|
||||
|
||||
// Only WSL UNC paths are readable; the guest Linux path is not (as on a real
|
||||
// Windows host, where it would misresolve against the current drive).
|
||||
const fsState = vi.hoisted(() => ({ existsAll: false }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsModule>()
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (path: string) => path.startsWith('\\\\wsl.localhost\\') || actual.existsSync(path)
|
||||
existsSync: (path: string) =>
|
||||
fsState.existsAll || path.startsWith('\\\\wsl.localhost\\') || actual.existsSync(path)
|
||||
}
|
||||
})
|
||||
|
||||
const scanned = vi.hoisted(() => ({ dirs: [] as string[] }))
|
||||
const HOST_ROLLOUT = 'C:\\host\\sessions\\rollout-wsl-sess.jsonl'
|
||||
const scanned = vi.hoisted(() => ({ dirs: [] as string[], hostRootHasRollout: false }))
|
||||
vi.mock('../ai-vault/session-scanner-discovery', () => ({
|
||||
walkSessionFiles: async (dir: string) => {
|
||||
scanned.dirs.push(dir)
|
||||
return []
|
||||
const isWslRoot = dir.startsWith('\\\\wsl.localhost\\')
|
||||
return scanned.hostRootHasRollout && !isWslRoot
|
||||
? ['C:\\host\\sessions\\rollout-wsl-sess.jsonl']
|
||||
: []
|
||||
}
|
||||
}))
|
||||
|
||||
import { resetHostReadableTranscriptPathCacheForTests } from './host-readable-transcript-path'
|
||||
import { resolveSessionFilePath } from './session-file-resolver'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
|
||||
const realPlatform = process.platform
|
||||
|
||||
@@ -42,7 +49,11 @@ function setPlatform(platform: NodeJS.Platform): void {
|
||||
|
||||
beforeEach(() => {
|
||||
resetHostReadableTranscriptPathCacheForTests()
|
||||
vi.mocked(getWslHomeAsync).mockClear()
|
||||
vi.mocked(listWslDistrosAsync).mockClear()
|
||||
scanned.dirs = []
|
||||
scanned.hostRootHasRollout = false
|
||||
fsState.existsAll = false
|
||||
setPlatform('win32')
|
||||
})
|
||||
|
||||
@@ -65,6 +76,19 @@ describe('resolveSessionFilePath on a Windows host with WSL', () => {
|
||||
expect(scanned.dirs).toContain(`${UBUNTU_HOME}\\.codex\\sessions`)
|
||||
})
|
||||
|
||||
it('does not enumerate WSL distros when a host Codex root already has the rollout', async () => {
|
||||
// Why: listing WSL homes spawns wsl.exe per distro, which boots distros the
|
||||
// user deliberately left stopped. It must stay a last resort.
|
||||
fsState.existsAll = true
|
||||
scanned.hostRootHasRollout = true
|
||||
|
||||
await expect(resolveSessionFilePath('codex', 'wsl-sess')).resolves.toBe(HOST_ROLLOUT)
|
||||
|
||||
expect(scanned.dirs.some((dir) => dir.startsWith('\\\\wsl.localhost\\'))).toBe(false)
|
||||
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the guest path alone on non-Windows hosts', async () => {
|
||||
setPlatform('darwin')
|
||||
const resolved = await resolveSessionFilePath('codex', 'wsl-sess', {
|
||||
|
||||
@@ -25,14 +25,12 @@ function claudeProjectsDir(): string {
|
||||
// `<managed home>/sessions`, NOT `~/.codex/sessions`. Search the managed home
|
||||
// first (that's where this main process's Codex sessions actually live), then
|
||||
// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves.
|
||||
// On Windows also search each WSL distro's managed + system Codex homes last, so
|
||||
// a WSL-hosted session still resolves when the hook path is Linux-only (#10326).
|
||||
// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice.
|
||||
async function codexSessionsDirs(): Promise<string[]> {
|
||||
// WSL roots are a separate lazy tier — see resolveCodexSessionFile.
|
||||
function codexSessionsDirs(): string[] {
|
||||
const candidates = [
|
||||
join(getOrcaManagedCodexHomePath(), 'sessions'),
|
||||
join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions'),
|
||||
...(await wslCodexSessionsDirs())
|
||||
join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions')
|
||||
]
|
||||
return candidates.filter((dir, index) => candidates.indexOf(dir) === index)
|
||||
}
|
||||
@@ -96,9 +94,13 @@ export async function resolveSessionFilePath(
|
||||
return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir())
|
||||
}
|
||||
if (transcriptAgent === 'codex') {
|
||||
const overrideDirs = options.codexSessionsDirs
|
||||
return resolveCodexSessionFile(
|
||||
trimmedId,
|
||||
options.codexSessionsDirs ?? (await codexSessionsDirs())
|
||||
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
|
||||
)
|
||||
}
|
||||
if (transcriptAgent === 'grok') {
|
||||
@@ -121,11 +123,29 @@ async function resolveClaudeSessionFile(
|
||||
|
||||
async function resolveCodexSessionFile(
|
||||
sessionId: string,
|
||||
sessionsDirs: string[]
|
||||
sessionsDirs: string[],
|
||||
loadFallbackDirs?: () => Promise<string[]>
|
||||
): 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)
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
if (!loadFallbackDirs) {
|
||||
return null
|
||||
}
|
||||
// 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))
|
||||
return findCodexRolloutInDirs(sessionId, fallbackDirs)
|
||||
}
|
||||
|
||||
async function findCodexRolloutInDirs(
|
||||
sessionId: string,
|
||||
sessionsDirs: string[]
|
||||
): Promise<string | null> {
|
||||
for (const sessionsDir of sessionsDirs) {
|
||||
if (!existsSync(sessionsDir)) {
|
||||
continue
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as HostReadableTranscriptPathModule from './host-readable-transcript-path'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
install: vi.fn(),
|
||||
resolve: vi.fn()
|
||||
resolve: vi.fn(),
|
||||
toHostReadable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-file-resolver', () => ({
|
||||
@@ -12,17 +14,35 @@ vi.mock('./transcript-watch-engine', () => ({
|
||||
getActiveNativeChatWatcherCount: vi.fn(() => 0),
|
||||
installTranscriptWatcher: mocks.install
|
||||
}))
|
||||
vi.mock('./host-readable-transcript-path', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof HostReadableTranscriptPathModule>()),
|
||||
toHostReadableTranscriptPath: mocks.toHostReadable
|
||||
}))
|
||||
|
||||
import { subscribeNativeChatTranscript } from './transcript-watch'
|
||||
|
||||
const realPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
describe('native chat transcript resolve polling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.install.mockReset().mockReturnValue(null)
|
||||
mocks.resolve.mockReset().mockResolvedValue(null)
|
||||
mocks.toHostReadable.mockReset().mockResolvedValue(null)
|
||||
// Why: a POSIX exact path is a WSL guest path on win32 and is deliberately
|
||||
// never installed raw there, so pin the platform instead of inheriting the
|
||||
// host's — otherwise these cases only hold on non-Windows machines.
|
||||
setPlatform('linux')
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
afterEach(() => {
|
||||
setPlatform(realPlatform)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => {
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
@@ -47,6 +67,51 @@ describe('native chat transcript resolve polling', () => {
|
||||
expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe)
|
||||
})
|
||||
|
||||
it('retries the WSL translation on the slow cadence, never installing the raw guest path', async () => {
|
||||
// Why: each translation sync-stats the UNC twin per distro over the 9P
|
||||
// share; doing it every fast tick would hammer the main process (#10326).
|
||||
setPlatform('win32')
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-id',
|
||||
transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl',
|
||||
resolvePollIntervalMs: 10,
|
||||
onAppend: () => {}
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(mocks.toHostReadable).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_100)
|
||||
expect(mocks.toHostReadable).toHaveBeenCalledTimes(2)
|
||||
|
||||
subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('installs the translated UNC path once the WSL transcript becomes readable', async () => {
|
||||
setPlatform('win32')
|
||||
const unc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-session-id.jsonl'
|
||||
mocks.toHostReadable.mockResolvedValue(unc)
|
||||
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-id',
|
||||
transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl',
|
||||
resolvePollIntervalMs: 10,
|
||||
onAppend: () => {}
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(mocks.install.mock.calls.some(([filePath]) => filePath === unc)).toBe(true)
|
||||
// Memoized: a successful translation is not re-probed on later ticks.
|
||||
expect(mocks.toHostReadable).toHaveBeenCalledTimes(1)
|
||||
|
||||
subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('keeps resolving on every retry when no exact hook path is available', async () => {
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
agent: 'claude',
|
||||
|
||||
@@ -68,6 +68,7 @@ function subscribeViaResolvePoll(
|
||||
// UNC twin is resolved lazily (the distro may still be cold) and memoized so
|
||||
// the exact-path install doesn't wait on the slower id-glob (#10326).
|
||||
let hostReadableExactPath: string | null = null
|
||||
let lastWslTranslateAt = 0
|
||||
|
||||
function scheduleAttempt(): void {
|
||||
if (closed) {
|
||||
@@ -100,11 +101,19 @@ function subscribeViaResolvePoll(
|
||||
let result: NativeChatTranscriptSubscription | null
|
||||
try {
|
||||
if (exactPath && !hostReadableExactPath) {
|
||||
// Non-WSL paths stay raw: installTranscriptWatcher already handles a
|
||||
// not-yet-created file, so don't spend an extra probe per tick.
|
||||
hostReadableExactPath = needsWslHostTranslation(exactPath)
|
||||
? await toHostReadableTranscriptPath(exactPath)
|
||||
: exactPath
|
||||
if (!needsWslHostTranslation(exactPath)) {
|
||||
// Non-WSL paths stay raw: installTranscriptWatcher already handles a
|
||||
// not-yet-created file, so don't spend an extra probe per tick.
|
||||
hostReadableExactPath = exactPath
|
||||
} else if (Date.now() - lastWslTranslateAt >= FALLBACK_RESOLVE_POLL_MS) {
|
||||
// Why: translating sync-stats the UNC twin per distro over the 9P
|
||||
// share, and the guest file usually appears well after the hook does,
|
||||
// so retry on the slow cadence rather than every fast tick. The raw
|
||||
// guest path is never installed on Windows — it would resolve against
|
||||
// the current drive (`C:\home\…`) and bind chat to a look-alike file.
|
||||
lastWslTranslateAt = Date.now()
|
||||
hostReadableExactPath = await toHostReadableTranscriptPath(exactPath)
|
||||
}
|
||||
}
|
||||
result = hostReadableExactPath
|
||||
? await attemptInstall({ ...args, filePath: hostReadableExactPath }, decode)
|
||||
|
||||
Reference in New Issue
Block a user