From 25f4b45dc41ccf8867de0dcf59d76af87987ce64 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:22:47 -0700 Subject: [PATCH] fix(wsl): close the session scanner's post-stop boot gaps (#35729) * fix(wsl): close the session scanner's post-stop boot gaps An OpenCode/ZCode WSL reader that exits notes its distro may have stopped, so a respawn needs a settled running read. The Devin sessions.db opens only on a running list from the transcript lease. Queued transcript reads re-admit right before they run, and reads on open 9P handles re-admit too. WSL roots and extra Codex homes come from a confirmed running list (E9). * fix(wsl): a failed running-list probe keeps WSL sessions and delete roots Background discovery keeps confirmed-only WSL roots, but an unconfirmed probe now carries over the last confirmed WSL rows (marked partial) instead of dropping them. Path checks for user acts (delete, subagents, transcript lookup) use last-known-good roots, since checking a path against a root reads nothing. --- .../ai-vault-wsl-session-carryover.ts | 48 +++++++ src/main/ai-vault/cached-session-list.test.ts | 116 +++++++++++++++-- src/main/ai-vault/cached-session-list.ts | 108 +++++++++++----- src/main/ai-vault/session-scanner-devin-db.ts | 12 ++ ...-scanner-opencode-sqlite-process-client.ts | 3 + ...on-scanner-opencode-sqlite-process.test.ts | 34 +++++ .../session-scanner-opencode-wsl-client.ts | 5 +- .../session-scanner-wsl-stop-gaps.test.ts | 122 ++++++++++++++++++ .../native-chat/wsl-transcript-fs-access.ts | 15 ++- .../wsl-transcript-running-admission.test.ts | 55 +++++++- src/main/wsl/wsl-home-unless-stopped.ts | 35 ++++- 11 files changed, 498 insertions(+), 55 deletions(-) create mode 100644 src/main/ai-vault/ai-vault-wsl-session-carryover.ts create mode 100644 src/main/ai-vault/session-scanner-wsl-stop-gaps.test.ts 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()) +}