diff --git a/src/main/ai-vault/ai-vault-wsl-session-carryover.ts b/src/main/ai-vault/ai-vault-wsl-session-carryover.ts new file mode 100644 index 00000000000..75592fb5f82 --- /dev/null +++ b/src/main/ai-vault/ai-vault-wsl-session-carryover.ts @@ -0,0 +1,48 @@ +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import { isWslUncPath } from '../../shared/wsl-paths' + +// The WSL rows of the last scan whose WSL roots a confirmed running list named, per scope key. +const lastConfirmedWslSessions = new Map() + +const isWslSession = (session: AiVaultSession): boolean => isWslUncPath(session.filePath) + +/** + * A failed running-list probe says nothing about WSL: keep the last confirmed WSL rows, marked + * partial, rather than letting every WSL session vanish until the probe recovers. + */ +export function carryOverUnconfirmedWslSessions( + scopeKey: string, + result: AiVaultListResult, + wslConfirmed: boolean +): AiVaultListResult { + if (wslConfirmed) { + lastConfirmedWslSessions.set(scopeKey, result.sessions.filter(isWslSession)) + return result + } + const present = new Set(result.sessions.map((session) => session.id)) + const carried = (lastConfirmedWslSessions.get(scopeKey) ?? []).filter( + (session) => !present.has(session.id) + ) + if (carried.length === 0) { + return result + } + return { + ...result, + sessions: [...result.sessions, ...carried].sort((a, b) => + b.modifiedAt.localeCompare(a.modifiedAt) + ), + issues: [ + ...result.issues, + { + agent: carried[0]?.agent ?? 'codex', + kind: 'notice', + path: '', + message: 'WSL could not be checked; its sessions are from the last scan.' + } + ] + } +} + +export function resetAiVaultWslSessionCarryover(): void { + lastConfirmedWslSessions.clear() +} diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index 9840874a0e3..b165941a7ac 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -14,13 +14,17 @@ const { filterPathsToRunningWslDistrosAsync, getCachedWslDistros, hasCachedWslDistros, - listRunningWslHomeDirsUnlessStopped, + readRunningWslHomeDirsUnlessStopped, + listLastKnownRunningWslHomeDirs, scanAiVaultSessionsInService } = vi.hoisted(() => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), getCachedWslDistros: vi.fn((): string[] | null => null), hasCachedWslDistros: vi.fn(() => false), - listRunningWslHomeDirsUnlessStopped: vi.fn().mockResolvedValue([]), + readRunningWslHomeDirsUnlessStopped: vi.fn( + async (): Promise<{ homes: string[]; confirmed: boolean }> => ({ homes: [], confirmed: true }) + ), + listLastKnownRunningWslHomeDirs: vi.fn(async (): Promise => []), scanAiVaultSessionsInService: vi.fn() })) @@ -32,7 +36,10 @@ vi.mock('../wsl', () => ({ getCachedWslDistros, hasCachedWslDistros })) -vi.mock('../wsl/wsl-home-unless-stopped', () => ({ listRunningWslHomeDirsUnlessStopped })) +vi.mock('../wsl/wsl-home-unless-stopped', () => ({ + readRunningWslHomeDirsUnlessStopped, + listLastKnownRunningWslHomeDirs +})) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync })) import { @@ -72,7 +79,10 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { filterPathsToRunningWslDistrosAsync.mockClear() getCachedWslDistros.mockReset().mockReturnValue(null) hasCachedWslDistros.mockReset().mockReturnValue(false) - listRunningWslHomeDirsUnlessStopped.mockReset().mockResolvedValue([]) + readRunningWslHomeDirsUnlessStopped + .mockReset() + .mockResolvedValue({ homes: [], confirmed: true }) + listLastKnownRunningWslHomeDirs.mockReset().mockResolvedValue([]) scanAiVaultSessionsInService.mockReset() }) afterEach(() => { @@ -152,7 +162,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { expect(cached.scannedAt).toBe('scan-A') expect(cached.scanId).toBe(first.scanId) expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) - expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1) + expect(readRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1) }) it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => { @@ -160,22 +170,110 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { getCachedWslDistros.mockReturnValue([]) await expect(getAiVaultWslHomeDirs()).resolves.toEqual([]) - expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled() + expect(listLastKnownRunningWslHomeDirs).not.toHaveBeenCalled() }) it('still discovers running distros before any distro probe has succeeded', async () => { hasCachedWslDistros.mockReturnValue(false) - listRunningWslHomeDirsUnlessStopped.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) + listLastKnownRunningWslHomeDirs.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) - expect(listRunningWslHomeDirsUnlessStopped).toHaveBeenCalledTimes(1) + expect(listLastKnownRunningWslHomeDirs).toHaveBeenCalledTimes(1) }) it('skips WSL home discovery off Windows', async () => { platform = 'linux' await expect(getAiVaultWslHomeDirs()).resolves.toEqual([]) - expect(listRunningWslHomeDirsUnlessStopped).not.toHaveBeenCalled() + expect(listLastKnownRunningWslHomeDirs).not.toHaveBeenCalled() + }) +}) + +const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' + +function session(id: string, filePath: string, modifiedAt: string): AiVaultSession { + return { + id, + executionHostId: 'local', + agent: 'claude', + sessionId: id, + title: id, + cwd: null, + branch: null, + model: null, + filePath, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt, + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } +} + +// #35729 review: a failed running-list probe is not a stopped distro; WSL history must not vanish. +describe('WSL sessions while the running list cannot be confirmed', () => { + beforeEach(() => { + platform = 'win32' + vi.spyOn(process, 'platform', 'get').mockImplementation(() => platform) + resetAiVaultSessionListCacheForTests() + resetLocalAiVaultScanProgressForTests() + hasCachedWslDistros.mockReset().mockReturnValue(false) + scanAiVaultSessionsInService.mockReset() + }) + afterEach(() => { + resetAiVaultSessionListCacheForTests() + resetLocalAiVaultScanProgressForTests() + vi.restoreAllMocks() + }) + + it('keeps the last confirmed WSL rows, marked partial, and keeps the delete roots', async () => { + const native = session('native', '/Users/ada/.claude/projects/p/native.jsonl', '2026-01-02') + const wsl = session('wsl', `${WSL_HOME}\\.claude\\projects\\p\\wsl.jsonl`, '2026-01-03') + readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [WSL_HOME], confirmed: true }) + scanAiVaultSessionsInService.mockResolvedValueOnce({ + sessions: [wsl, native], + issues: [], + scannedAt: 'confirmed' + }) + await listAiVaultSessions({ force: true }) + + // The next probe fails: the scanner gets no WSL roots and finds only native history. + readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [], confirmed: false }) + listLastKnownRunningWslHomeDirs.mockResolvedValue([WSL_HOME]) + scanAiVaultSessionsInService.mockResolvedValueOnce({ + sessions: [native], + issues: [], + scannedAt: 'unconfirmed' + }) + const unconfirmed = await listAiVaultSessions({ force: true }) + + expect(scanAiVaultSessionsInService.mock.calls[1]?.[0]).toMatchObject({ wslHomeDirs: [] }) + expect(unconfirmed.sessions.map((row) => row.id)).toEqual(['wsl', 'native']) + expect(unconfirmed.issues).toEqual([expect.objectContaining({ kind: 'notice' })]) + // Checking a delete or subagent path against a root reads nothing, so it keeps last-known-good. + await expect(getAiVaultWslHomeDirs()).resolves.toEqual([WSL_HOME]) + }) + + it('never carries a WSL row back after a delete invalidated the list', async () => { + const wsl = session('wsl', `${WSL_HOME}\\.claude\\projects\\p\\wsl.jsonl`, '2026-01-03') + readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [WSL_HOME], confirmed: true }) + scanAiVaultSessionsInService.mockResolvedValueOnce({ + sessions: [wsl], + issues: [], + scannedAt: 'confirmed' + }) + await listAiVaultSessions({ force: true }) + invalidateAiVaultSessionListCache() + + readRunningWslHomeDirsUnlessStopped.mockResolvedValue({ homes: [], confirmed: false }) + scanAiVaultSessionsInService.mockResolvedValueOnce(scanResult('unconfirmed')) + await expect(listAiVaultSessions({ force: true })).resolves.toMatchObject({ sessions: [] }) }) }) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 23296760e23..33bf7798355 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,3 +1,4 @@ +import { isWslUncPath } from '../../shared/wsl-paths' import { join } from 'node:path' import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { @@ -6,7 +7,14 @@ import { scanAiVaultSessionsInBackground } from './session-scanner-background' import { getCachedWslDistros, hasCachedWslDistros } from '../wsl' -import { listRunningWslHomeDirsUnlessStopped } from '../wsl/wsl-home-unless-stopped' +import { + listLastKnownRunningWslHomeDirs, + readRunningWslHomeDirsUnlessStopped +} from '../wsl/wsl-home-unless-stopped' +import { + carryOverUnconfirmedWslSessions, + resetAiVaultWslSessionCarryover +} from './ai-vault-wsl-session-carryover' import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import { isAiVaultScanCancelledError, @@ -68,30 +76,48 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi sources = next } +type LocalAiVaultScanRoots = Required< + Pick +> & + Pick< + AiVaultScanOptions, + | 'executionHostId' + | 'wslOpenCodeReaders' + | 'claudeProfileProjectsDirs' + | 'opencodeAccountDataDirs' + > + /** Resolve fresh WSL roots for both listing and indexing; distros can start or stop between scans. */ -export async function localAiVaultScanRoots(): Promise< - Required> & - Pick< - AiVaultScanOptions, - | 'executionHostId' - | 'wslOpenCodeReaders' - | 'claudeProfileProjectsDirs' - | 'opencodeAccountDataDirs' - > -> { - const [additionalCodexHomes, wslHomeDirs] = await Promise.all([ - filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths()), - getAiVaultWslHomeDirs() +export async function localAiVaultScanRoots(): Promise { + return (await resolveLocalAiVaultScanRoots()).roots +} + +/** `wslConfirmed` is false when no confirmed running list was read: WSL rows are then unknown, not gone. */ +async function resolveLocalAiVaultScanRoots(): Promise<{ + roots: LocalAiVaultScanRoots + wslConfirmed: boolean +}> { + const [additionalCodexHomes, wsl] = await Promise.all([ + // Confirmed only: a stale list could name a distro that stopped; a failed probe drops WSL homes. + filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths(), { + requireConfirmed: true + }).catch(() => configuredAdditionalCodexHomePaths().filter((home) => !isWslUncPath(home))), + scanWslHomeDirs() ]) return { - additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => join(homePath, 'sessions')), - wslHomeDirs, - // Scanner workers have no account router; supply profile roots from this process. - claudeProfileProjectsDirs: claudeProfileHistoryDirs('projects'), - opencodeAccountDataDirs: managedOpenCodeAccountDataDirs(), - wslOpenCodeReaders: await prepareOpenCodeWslReaders(wslHomeDirs), - // Runtime-addressed callers restamp host-local results at the RPC edge instead of rescanning. - executionHostId: LOCAL_EXECUTION_HOST_ID + wslConfirmed: wsl.confirmed, + roots: { + additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => + join(homePath, 'sessions') + ), + wslHomeDirs: wsl.homes, + // Scanner workers have no account router; supply profile roots from this process. + claudeProfileProjectsDirs: claudeProfileHistoryDirs('projects'), + opencodeAccountDataDirs: managedOpenCodeAccountDataDirs(), + wslOpenCodeReaders: await prepareOpenCodeWslReaders(wsl.homes), + // Runtime-addressed callers restamp host-local results at the RPC edge instead of rescanning. + executionHostId: LOCAL_EXECUTION_HOST_ID + } } } @@ -139,18 +165,23 @@ export async function listAiVaultSessions( throw new Error('Local scan progress was not created.') } const scope = await resolveAiVaultScopeRealPaths(args?.scopePaths ?? []) + const { roots, wslConfirmed } = await resolveLocalAiVaultScanRoots() const scanned = await scanAiVaultSessionsInBackground( { includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions, limit: args?.limit, unlimited: args?.unlimited, scopePaths: args?.scopePaths ? scope.scanScopePaths : undefined, - ...(await localAiVaultScanRoots()) + ...roots }, scanSignal, scanProgress ) - const result = { ...attachAiVaultScopeCwdAliases(scanned, scope), scanId } + const result = carryOverUnconfirmedWslSessions( + key, + { ...attachAiVaultScopeCwdAliases(scanned, scope), scanId }, + wslConfirmed + ) // Return pre-invalidation results to their caller, but never repopulate the cache with them. if (!scanSignal.aborted && startGeneration === cacheGeneration) { const current = cachedList @@ -173,16 +204,29 @@ export async function listAiVaultSessions( }) } -// Subagent transcript validation shares these WSL-aware roots with discovery. +/** Trust cached absence of any distro; otherwise a confirmed running list (E9). */ +async function scanWslHomeDirs(): Promise<{ homes: string[]; confirmed: boolean }> { + if ( + process.platform !== 'win32' || + (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) + ) { + return { homes: [], confirmed: true } + } + return readRunningWslHomeDirsUnlessStopped() +} + +/** + * WSL roots for checking a path a user act names (delete, subagents, transcript lookup). Checking + * reads nothing, so a failed probe must not narrow them: it keeps the last-known-good list. + */ export async function getAiVaultWslHomeDirs(): Promise { - if (process.platform !== 'win32') { + if ( + process.platform !== 'win32' || + (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) + ) { return [] } - // Trust only cached absence; a failed or new probe must not narrow WSL roots used by deletion and subagent validation. - if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) { - return [] - } - return listRunningWslHomeDirsUnlessStopped() + return listLastKnownRunningWslHomeDirs() } export function aiVaultSessionListCacheGeneration(): number { @@ -193,6 +237,8 @@ export function aiVaultSessionListCacheGeneration(): number { export function invalidateAiVaultSessionListCache(): void { cacheGeneration++ cachedList = null + // A deleted WSL session must not come back as a carried-over row. + resetAiVaultWslSessionCarryover() } export function resetAiVaultSessionListCacheForTests(): void { diff --git a/src/main/ai-vault/session-scanner-devin-db.ts b/src/main/ai-vault/session-scanner-devin-db.ts index 83d38b431b2..dc65d2f2ede 100644 --- a/src/main/ai-vault/session-scanner-devin-db.ts +++ b/src/main/ai-vault/session-scanner-devin-db.ts @@ -1,3 +1,6 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' +import { wasWslDistroLastSeenRunningSettled } from '../wsl-background-distro-gate' +import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from '../native-chat/wsl-transcript-running-admission' import { AsyncLocalStorage } from 'node:async_hooks' import { dirname, join } from 'node:path' import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access' @@ -95,6 +98,15 @@ export function devinSessionsIndexForSidecar(sidecar: SessionSidecarObservation indexCache.set(dbPath, cached) return { index: cached.index, unreadable: false } } + // Opening the database over 9P boots a stopped distro; this read is synchronous, so it trusts only + // a running list from the transcript lease (the sidecar stat just before took one). + const distro = process.platform === 'win32' ? parseWslUncPath(dbPath)?.distro : undefined + if ( + distro && + !wasWslDistroLastSeenRunningSettled(distro, { maxAgeMs: WSL_TRANSCRIPT_RUNNING_LEASE_MS }) + ) { + return { index: null, unreadable: true } + } try { const index = readDevinSessionsIndex(dbPath) if (indexCache.size >= INDEX_CACHE_LIMIT) { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts index 953fe7633f8..122411eb7c1 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts @@ -19,6 +19,8 @@ export type OpenCodeSqliteProcessOptions = { requestTimeoutMs?: number idleTeardownMs?: number beforeSpawn?: (signal: AbortSignal) => Promise + /** The child exited without being retired, e.g. its WSL distro stopped under it. */ + onUnexpectedExit?: () => void } function createProcessTransport(options: OpenCodeSqliteProcessOptions): WorkerRequestTransport { @@ -68,6 +70,7 @@ function createProcessTransport(options: OpenCodeSqliteProcessOptions): WorkerRe child.stderr.on('error', fault) child.on('exit', (code) => { if (!retired) { + options.onUnexpectedExit?.() events.emit('exit', code ?? 1) } }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts index 72e7ea31c16..7e0cd71db6d 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts @@ -238,4 +238,38 @@ describe('OpenCode SQLite process retirement', () => { noisy.dispose() } }) + + // A WSL reader dies with its distro; the client reports that so the respawn re-checks the distro. + it('reports an unexpected child exit, but not its own retirement', async () => { + const onUnexpectedExit = vi.fn() + const dying = createOpenCodeSqliteProcessClient({ + executable, + args: ['-e', 'process.exit(3)'], + onUnexpectedExit + }) + try { + await dying.list({ dbPaths: [dbPath], limit: 1, issues: [] }) + await vi.waitFor(() => expect(onUnexpectedExit).toHaveBeenCalled()) + } finally { + dying.dispose() + } + + const spawn = vi.spyOn(processRunner, 'spawnProcess') + const retiredExit = vi.fn() + const healthy = createOpenCodeSqliteProcessClient({ + executable, + args: [entry], + onUnexpectedExit: retiredExit + }) + try { + await healthy.list({ dbPaths: [dbPath], limit: 1, issues: [] }) + const child = spawn.mock.results[0]?.value + const exited = new Promise((resolve) => child?.once('exit', resolve)) + healthy.dispose() + await exited + expect(retiredExit).not.toHaveBeenCalled() + } finally { + spawn.mockRestore() + } + }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-client.ts b/src/main/ai-vault/session-scanner-opencode-wsl-client.ts index 268e8efa7e0..74229c52e5b 100644 --- a/src/main/ai-vault/session-scanner-opencode-wsl-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-wsl-client.ts @@ -5,6 +5,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { admitWslTranscriptPath } from '../native-chat/wsl-transcript-running-admission' +import { noteWslDistroMayHaveStopped } from '../wsl/wsl-unc-access-gate' import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client' import type { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client' import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' @@ -73,7 +74,9 @@ export async function openCodeWslClient( async beforeSpawn(spawnSignal) { // A `--exec` of a stopped distro boots it; every respawn asks again. await waitForPromiseWithSignal(admitWslTranscriptPath(dbPath), spawnSignal) - } + }, + // The reader dying may mean its distro stopped: a respawn must not trust an older running read. + onUnexpectedExit: () => noteWslDistroMayHaveStopped(distro) }) clients.set(key, client) } diff --git a/src/main/ai-vault/session-scanner-wsl-stop-gaps.test.ts b/src/main/ai-vault/session-scanner-wsl-stop-gaps.test.ts new file mode 100644 index 00000000000..d1d9b19a8e9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-wsl-stop-gaps.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '@orca/process-host/process-spec' +import type { OpenCodeSqliteProcessOptions } from './session-scanner-opencode-sqlite-process-client' + +const mocks = vi.hoisted(() => ({ + runProcess: vi.fn<(spec: { args?: string[] }) => Promise>(), + running: { distros: ['Ubuntu'] }, + create: vi.fn((_options: OpenCodeSqliteProcessOptions) => ({ dispose: vi.fn() })), + readDb: vi.fn(() => new Map()) +})) + +vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess })) +vi.mock('./session-scanner-opencode-sqlite-process-client', () => ({ + createOpenCodeSqliteProcessClient: mocks.create +})) +vi.mock('./session-scanner-opencode-sqlite-open', async (importOriginal) => ({ + ...(await importOriginal()), + readOpenCodeDatabase: mocks.readDb +})) +vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' })) +vi.mock('../wsl-interop-spawn-directory', () => ({ resolveWslInteropSpawnCwd: () => 'C:\\' })) + +import { _resetWslCachesForTests } from '../wsl' +import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate' +import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from '../native-chat/wsl-transcript-running-admission' +import { + configureOpenCodeWslReaders, + openCodeWslClient +} from './session-scanner-opencode-wsl-client' +import { + devinSessionsIndexForSidecar, + resetDevinSessionsIndexCacheForTests +} from './session-scanner-devin-db' +import { listRunningWslHomeDirsUnlessStopped } from '../wsl/wsl-home-unless-stopped' +import { clearWslHomeCache, rememberWslHome } from '../wsl-home-cache' +import { _resetRunningWslDistroCacheForTests } from '../wsl-running-distro-cache' +import { localAiVaultScanRoots } from './cached-session-list' + +const DB = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.opencode\\opencode.db' +const DEVIN_DB = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.devin\\sessions.db' +let originalPlatform: NodeJS.Platform + +beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.running.distros = ['Ubuntu'] + mocks.runProcess.mockReset().mockImplementation(async (spec) => { + if (!spec.args?.includes('--running')) { + return { code: 1, signal: null, stdout: '', stderr: 'unexpected', timedOut: false } + } + const stdout = mocks.running.distros.map((distro) => `${distro}\n`).join('') + return { code: 0, signal: null, stdout, stderr: '', timedOut: false } + }) + mocks.create.mockClear() + mocks.readDb.mockClear() + resetDevinSessionsIndexCacheForTests() +}) + +afterEach(() => { + configureOpenCodeWslReaders([]) + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + _resetWslCachesForTests() + _resetWslBackgroundDistroGateForTests() + _resetRunningWslDistroCacheForTests() + clearWslHomeCache() + vi.useRealTimers() +}) + +const listProbes = (): number => + mocks.runProcess.mock.calls.filter(([spec]) => spec.args?.includes('--running')).length + +describe('session scanner after a `wsl --terminate`', () => { + // verify-18 (a): the reader died with its distro, and its respawn inside the lease booted it. + it('re-reads the running list before respawning an OpenCode reader that exited', async () => { + configureOpenCodeWslReaders([ + { distro: 'Ubuntu', executable: '/usr/bin/node', readerPath: '/r.cjs' } + ]) + await openCodeWslClient('Ubuntu', DB) + const options = mocks.create.mock.calls[0]?.[0] + await options?.beforeSpawn?.(new AbortController().signal) + expect(listProbes()).toBe(1) + + mocks.running.distros = [] + options?.onUnexpectedExit?.() + + await expect(options?.beforeSpawn?.(new AbortController().signal)).rejects.toThrow( + 'is not running' + ) + expect(listProbes()).toBe(2) + }) + + it('opens a WSL Devin sessions.db only on a running list from the transcript lease', async () => { + vi.useFakeTimers({ toFake: ['performance', 'Date', 'setTimeout'] }) + const sidecar = { path: DEVIN_DB, mtimeMs: 1, sizeBytes: 2 } + await listRunningWslHomeDirsUnlessStopped() + expect(devinSessionsIndexForSidecar(sidecar)).toMatchObject({ unreadable: false }) + expect(mocks.readDb).toHaveBeenCalledOnce() + + resetDevinSessionsIndexCacheForTests() + vi.advanceTimersByTime(WSL_TRANSCRIPT_RUNNING_LEASE_MS + 1) + expect(devinSessionsIndexForSidecar(sidecar)).toEqual({ index: null, unreadable: true }) + expect(mocks.readDb).toHaveBeenCalledOnce() + }) + + it('lists no WSL root from a stale list when the running list cannot be confirmed', async () => { + const home = '\\\\wsl.localhost\\Ubuntu\\home\\ada' + rememberWslHome('Ubuntu', home) + await expect(listRunningWslHomeDirsUnlessStopped()).resolves.toEqual([home]) + + // The probe now fails; the last-known-good list still names Ubuntu, whose home is cached. + mocks.runProcess.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: 'wsl failed', + timedOut: false + }) + await expect(listRunningWslHomeDirsUnlessStopped()).resolves.toEqual([]) + const roots = await localAiVaultScanRoots() + expect(roots.wslHomeDirs).toEqual([]) + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts index b8195bb53a2..82607b9017c 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -45,12 +45,15 @@ function runReusableFsOperation( ? admitWslTranscriptPath(request.path).then(() => runWslTranscriptFsTask( { operation: request.operation, path: request.path, priority, signal }, - (taskSignal) => - runWslTranscriptFsProcess( + async (taskSignal) => { + // Queued work runs later: ask again right before the touch. + await admitWslTranscriptPath(request.path) + return runWslTranscriptFsProcess( request, taskSignal, wslTranscriptFsLaneKey(request.path, priority) ) + } ) ) : localTask() @@ -144,8 +147,10 @@ async function openAdmittedWslTranscriptFile( dedupe: false, onAbandonedResult: (handle) => void closeTranscriptHandle(handle, path) }, - (taskSignal) => - openWslTranscriptFsProcess(path, taskSignal, wslTranscriptFsLaneKey(path, priority)) + async (taskSignal) => { + await admitWslTranscriptPath(path) + return openWslTranscriptFsProcess(path, taskSignal, wslTranscriptFsLaneKey(path, priority)) + } ) } @@ -171,6 +176,8 @@ export function readTranscriptFile( return runWslTranscriptFsTask( { operation: 'read', path, priority, signal, dedupe: false }, async (taskSignal) => { + // An open 9P handle still reconnects (and boots) a distro that stopped since the open. + await admitWslTranscriptPath(path) if (!isWslTranscriptFsProcessHandle(handle)) { return (handle as FileHandle).read(buffer, offset, length, position) } diff --git a/src/main/native-chat/wsl-transcript-running-admission.test.ts b/src/main/native-chat/wsl-transcript-running-admission.test.ts index 4adecf360b5..2fb13d371ff 100644 --- a/src/main/native-chat/wsl-transcript-running-admission.test.ts +++ b/src/main/native-chat/wsl-transcript-running-admission.test.ts @@ -3,19 +3,27 @@ import type { ProcessResult } from '@orca/process-host/process-spec' const mocks = vi.hoisted(() => ({ runProcess: vi.fn(), - runFsProcess: vi.fn(async () => []) + runFsProcess: vi.fn(async (..._args: unknown[]): Promise => []), + readFsProcess: vi.fn(async (..._args: unknown[]) => Buffer.from('x')) })) vi.mock('@orca/process-host', () => ({ runProcess: mocks.runProcess })) vi.mock('./wsl-transcript-fs-process-dispatch', async (importOriginal) => ({ ...(await importOriginal()), - runWslTranscriptFsProcess: mocks.runFsProcess + runWslTranscriptFsProcess: mocks.runFsProcess, + openWslTranscriptFsProcess: async () => ({ wslTranscriptFsProcessHandle: 1 }), + readWslTranscriptFsProcess: mocks.readFsProcess, + isWslTranscriptFsProcessHandle: () => true, + closeWslTranscriptFsProcess: async () => {} })) import { _resetWslCachesForTests } from '../wsl' -import { _resetWslBackgroundDistroGateForTests } from '../wsl-background-distro-gate' +import { + _resetWslBackgroundDistroGateForTests, + noteWslDistroMayHaveStopped +} from '../wsl-background-distro-gate' import { runAsWslUserAct } from '../wsl/wsl-unc-access-gate' -import { wslGatedReaddir } from './wsl-transcript-fs-access' +import { openTranscriptFile, readTranscriptFile, wslGatedReaddir } from './wsl-transcript-fs-access' import { resetWslTranscriptFsGateForTests, WslTranscriptFsError } from './wsl-transcript-fs-gate' import { WSL_TRANSCRIPT_RUNNING_LEASE_MS } from './wsl-transcript-running-admission' @@ -35,7 +43,8 @@ beforeEach(() => { originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) mocks.runProcess.mockReset() - mocks.runFsProcess.mockClear() + mocks.runFsProcess.mockReset().mockResolvedValue([]) + mocks.readFsProcess.mockClear() }) afterEach(() => { @@ -86,4 +95,40 @@ describe('transcript reads never boot a stopped WSL distro (E9)', () => { ).resolves.toEqual([]) expect(mocks.runFsProcess).toHaveBeenCalledOnce() }) + + // Admission at queue time is not enough: the distro can stop while the task waits for a slot. + it('asks again when a queued read finally runs, refusing it after a stop', async () => { + mocks.runProcess.mockResolvedValue(runningList('Ubuntu')) + const release: (() => void)[] = [] + mocks.runFsProcess.mockImplementation( + () => new Promise((resolve) => release.push(() => resolve([]))) + ) + // One task per distro lane runs at a time; the next one waits in the queue. + const busy = wslGatedReaddir(`${SESSIONS}\\a`, 'scan') + await vi.waitFor(() => expect(mocks.runFsProcess).toHaveBeenCalledTimes(1)) + const queued = wslGatedReaddir(`${SESSIONS}\\b`, 'scan') + await new Promise((resolve) => setImmediate(resolve)) + + noteWslDistroMayHaveStopped('Ubuntu') + mocks.runProcess.mockResolvedValue(runningList()) + release[0]?.() + await busy + + await expect(queued).rejects.toThrow('is not running') + expect(mocks.runFsProcess).toHaveBeenCalledTimes(1) + }) + + it('never reads an already-open handle of a distro that stopped since the open', async () => { + mocks.runProcess.mockResolvedValue(runningList('Ubuntu')) + const file = `${SESSIONS}\\s.jsonl` + const handle = await openTranscriptFile(file, 'exact') + + noteWslDistroMayHaveStopped('Ubuntu') + mocks.runProcess.mockResolvedValue(runningList()) + + await expect( + readTranscriptFile(handle, file, Buffer.alloc(1), 0, 1, 0, 'exact') + ).rejects.toThrow('is not running') + expect(mocks.readFsProcess).not.toHaveBeenCalled() + }) }) diff --git a/src/main/wsl/wsl-home-unless-stopped.ts b/src/main/wsl/wsl-home-unless-stopped.ts index 9e8c5d4f17f..0af8d519848 100644 --- a/src/main/wsl/wsl-home-unless-stopped.ts +++ b/src/main/wsl/wsl-home-unless-stopped.ts @@ -1,4 +1,5 @@ import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl' +import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' import { getCachedWslHome } from '../wsl-home-cache' import { blockedWslDistroForPath } from './wsl-unc-access-gate' @@ -10,10 +11,34 @@ export async function wslHomeUnlessStopped(distro: string): Promise { - const homes = await Promise.all( - (await listRunningWslDistrosAsync()).map((distro) => wslHomeUnlessStopped(distro)) - ) +async function homesOf(distros: readonly string[]): Promise { + const homes = await Promise.all(distros.map((distro) => wslHomeUnlessStopped(distro))) return homes.filter((home): home is string => Boolean(home)) } + +/** + * UNC homes of distros a fresh running list confirms, for background scans (E9). A failed probe + * lists none and says so (`confirmed: false`): a stale "running" answer plus a cached home would + * hand a stopped distro's root to the scanner. + */ +export async function readRunningWslHomeDirsUnlessStopped(): Promise<{ + homes: string[] + confirmed: boolean +}> { + const read = await readConfirmedRunningWslDistros() + return read.status === 'confirmed' + ? { homes: await homesOf(read.distros), confirmed: true } + : { homes: [], confirmed: false } +} + +export async function listRunningWslHomeDirsUnlessStopped(): Promise { + return (await readRunningWslHomeDirsUnlessStopped()).homes +} + +/** + * Homes for checking a path a user act names (delete, subagents, a transcript lookup): checking a + * path against a root reads nothing, so a failed probe keeps the last-known-good list. + */ +export async function listLastKnownRunningWslHomeDirs(): Promise { + return homesOf(await listRunningWslDistrosAsync()) +}