diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 567bf30df07..fa9aec8e48d 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -93,6 +93,24 @@ describe('HistoryReader', () => { expect(info!.rehydrateSequences).toBe('') }) + it('marks a folder only meta.json recorded as the spawn folder', async () => { + writeSessionWithCheckpoint(dir, 'with-cwd', makeMeta(), makeCheckpoint()) + writeSessionWithCheckpoint(dir, 'no-cwd', makeMeta(), makeCheckpoint({ cwd: null })) + writeSessionWithScrollback(dir, 'legacy', makeMeta(), 'old format data\r\n') + + const withCwd = await reader.detectColdRestoreState('with-cwd') + expect(withCwd.status === 'restored' && withCwd.cwdFromSpawn).toBeFalsy() + const noCwd = await reader.detectColdRestoreState('no-cwd') + expect(noCwd).toMatchObject({ + status: 'restored', + cwdFromSpawn: true, + restoreInfo: { cwd: '/home/user/project' } + }) + await expect(reader.detectColdRestoreState('legacy')).resolves.toMatchObject({ + cwdFromSpawn: true + }) + }) + it('restores pre-limit 800-column checkpoint history', async () => { writeSessionWithCheckpoint( dir, diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index bcd19fbca78..78a6d580ac0 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -27,33 +27,20 @@ import { } from './terminal-history-metadata' import { coldRestoreInfoFromSnapshot, - type ColdRestoreInfo + type ColdRestoreDetection, + type ColdRestoreInfo, + type IncrementalLogRestore } from './terminal-history-cold-restore-info' import { readTerminalHistoryCheckpoint } from './terminal-history-checkpoint-reader' import { isValidTerminalHistorySize } from './terminal-history-dimensions' -export type { ColdRestoreInfo } from './terminal-history-cold-restore-info' +export type { ColdRestoreDetection, ColdRestoreInfo } from './terminal-history-cold-restore-info' export type RestorableHistoryProbe = | { status: 'none' } | { status: 'restorable'; sessionId: string } | { status: 'unreadable'; sessionId: string } -export type ColdRestoreDetection = - | { status: 'none' } - | { - status: 'restored' - sessionId: string - restoreInfo: ColdRestoreInfo - hasUnreadableRecovery: boolean - } - | { status: 'unreadable'; sessionId: string } - -type IncrementalLogRestore = { - restoreInfo: ColdRestoreInfo | null - readFailed: boolean -} - // Why: parallel pane mounts should interleave with main-process work without multiplying replay slices per turn. const coldRestoreReplaySemaphore = new PrioritySemaphore(1) @@ -138,7 +125,8 @@ export class HistoryReader { status: 'restored', sessionId, restoreInfo: logRestore.restoreInfo, - hasUnreadableRecovery: checkpointReadFailed || logRestore.readFailed + hasUnreadableRecovery: checkpointReadFailed || logRestore.readFailed, + ...(logRestore.cwdFromSpawn ? { cwdFromSpawn: true } : {}) } } @@ -157,7 +145,8 @@ export class HistoryReader { status: 'restored', sessionId, restoreInfo: legacyRestore, - hasUnreadableRecovery: checkpointReadFailed || logRestore.readFailed + hasUnreadableRecovery: checkpointReadFailed || logRestore.readFailed, + cwdFromSpawn: true } } return checkpointReadFailed || logRestore.readFailed || legacyExists @@ -169,7 +158,8 @@ export class HistoryReader { status: 'restored', sessionId, restoreInfo: coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta), - hasUnreadableRecovery: logRestore.readFailed + hasUnreadableRecovery: logRestore.readFailed, + ...((checkpoint.cwd ?? null) === null ? { cwdFromSpawn: true } : {}) } } @@ -318,13 +308,15 @@ export class HistoryReader { } const snapshot = emulator.getSnapshot() const lastBatch = log.batches.at(-1)! + const cwd = snapshot.cwd ?? checkpoint?.cwd ?? null return { restoreInfo: coldRestoreInfoFromSnapshot( { ...snapshot, pendingOutputSeq: lastBatch.seq }, - snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd, + cwd, meta ), - readFailed: log.truncatedTail + readFailed: log.truncatedTail, + ...(cwd === null ? { cwdFromSpawn: true } : {}) } } catch { // Why: a replay failure must degrade to checkpoint-only restore, never diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts index 95b92d1cbdd..ddce131d981 100644 --- a/src/main/daemon/terminal-history-cold-restore-info.ts +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -19,6 +19,24 @@ export type ColdRestoreInfo = { terminalOwner?: TerminalOwner } +export type ColdRestoreDetection = + | { status: 'none' } + | { + status: 'restored' + sessionId: string + restoreInfo: ColdRestoreInfo + hasUnreadableRecovery: boolean + /** No snapshot recorded a folder, so `restoreInfo.cwd` is where the session started. */ + cwdFromSpawn?: true + } + | { status: 'unreadable'; sessionId: string } + +export type IncrementalLogRestore = { + restoreInfo: ColdRestoreInfo | null + readFailed: boolean + cwdFromSpawn?: true +} + type RestoredSnapshot = { snapshotAnsi: string scrollbackAnsi: string diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts index ca0258543c8..c618015a59b 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts @@ -1,17 +1,25 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { SessionInfo } from '../../daemon/types' import { proveWslMoveTerminalsExited } from './wsl-move-exit-proof' -type Census = Pick[] | null +type CensusSession = Pick & + Partial> +type Census = CensusSession[] | null const repoKey = 'repo::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' const scope = (workspaceKeys: string[], sessionIds: string[] = []) => ({ + distro: 'Ubuntu', workspaceKeys: new Set(workspaceKeys), sessionIds: new Set(sessionIds) }) const prove = (census: Census, keys = [repoKey], sessionIds: string[] = [], fallback = 0) => proveWslMoveTerminalsExited( - { listSessions: async () => census, countFallbackSessions: () => fallback }, + { + listSessions: async () => + census?.map((session) => ({ cwd: null, wslDistro: null, ...session })) ?? null, + countFallbackSessions: () => fallback, + isScopeFenced: () => true + }, scope(keys, sessionIds) ) @@ -43,6 +51,105 @@ describe('proveWslMoveTerminalsExited', () => { }) }) + it('matches a differently cased drvfs alias, so its live session never reads as exited', async () => { + const sessionId = 'repo:://wsl$/ubuntu/mnt/c/repo/@@7' + await expect( + prove([{ sessionId, isAlive: true }], ['repo::\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Repo']) + ).resolves.toEqual({ verdict: 'live', sessionIds: [sessionId] }) + }) + + it('matches a Linux-spelled scope key to a UNC-minted session', async () => { + const sessionId = `${repoKey}@@8` + await expect(prove([{ sessionId, isAlive: true }], ['repo::/home/dev/app/'])).resolves.toEqual({ + verdict: 'live', + sessionIds: [sessionId] + }) + }) + + it('attributes an unminted live session by its folder in the moved workspace', async () => { + await expect( + prove([{ sessionId: 'uuid-1', isAlive: true, cwd: '/home/dev/app/src', wslDistro: 'ubuntu' }]) + ).resolves.toEqual({ verdict: 'live', sessionIds: ['uuid-1'] }) + }) + + it.each([ + ['no folder', { cwd: null, wslDistro: null }], + ['another folder in the moving distro', { cwd: '/home/dev/other', wslDistro: 'Ubuntu' }], + ['a UNC folder in the moving distro', { cwd: '\\\\wsl$\\Ubuntu\\tmp', wslDistro: null }], + [ + 'a POSIX folder from a daemon that does not report its distro', + { cwd: '/home/dev/app', wslDistro: undefined } + ], + ['a native-tagged shell in a moved folder', { cwd: '/home/dev/app', wslDistro: null }] + ])('never proves exit past an unminted live session with %s', async (_label, facts) => { + await expect(prove([{ sessionId: 'uuid-2', isAlive: true, ...facts }])).resolves.toMatchObject({ + verdict: 'unverifiable' + }) + }) + + it.each([ + ['a native Windows folder', { cwd: 'C:\\src', wslDistro: null }], + ['a Git Bash folder', { cwd: '/c/src', wslDistro: null }], + ['another distro', { cwd: '/home/dev/app', wslDistro: 'Debian' }] + ])('ignores an unminted live session in %s', async (_label, facts) => { + await expect(prove([{ sessionId: 'uuid-3', isAlive: true, ...facts }])).resolves.toEqual({ + verdict: 'exited' + }) + }) + + it("counts another workspace's shell that changed into a moved folder", async () => { + const sessionId = 'other::\\\\wsl.localhost\\Ubuntu\\home\\dev\\other@@1' + await expect( + prove([{ sessionId, isAlive: true, cwd: '/home/dev/app/src', wslDistro: 'Ubuntu' }]) + ).resolves.toEqual({ verdict: 'live', sessionIds: [sessionId] }) + await expect( + prove([{ sessionId, isAlive: true, cwd: '/home/dev/other', wslDistro: 'Ubuntu' }]) + ).resolves.toEqual({ verdict: 'exited' }) + }) + + it('cannot prove exit when the fence lifts during the census', async () => { + let fenced = true + await expect( + proveWslMoveTerminalsExited( + { + listSessions: async () => { + fenced = false + return [] + }, + countFallbackSessions: () => 0, + isScopeFenced: () => fenced + }, + scope([repoKey]) + ) + ).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('refuses to prove exit before new terminals are fenced', async () => { + const listSessions = vi.fn(async () => []) + await expect( + proveWslMoveTerminalsExited( + { listSessions, countFallbackSessions: () => 0, isScopeFenced: () => false }, + scope([repoKey]) + ) + ).resolves.toMatchObject({ verdict: 'unverifiable' }) + expect(listSessions).not.toHaveBeenCalled() + }) + + it('reads a failing fallback count as unverifiable', async () => { + await expect( + proveWslMoveTerminalsExited( + { + listSessions: async () => [], + countFallbackSessions: () => { + throw new Error('fallback census failed') + }, + isScopeFenced: () => true + }, + scope([repoKey]) + ) + ).resolves.toEqual({ verdict: 'unverifiable', reason: 'fallback census failed' }) + }) + it('keeps the Linux part case-sensitive', async () => { await expect( prove([{ sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\dev\\App@@6', isAlive: true }]) @@ -68,7 +175,8 @@ describe('proveWslMoveTerminalsExited', () => { listSessions: async () => { throw new Error('daemon unreachable') }, - countFallbackSessions: () => 0 + countFallbackSessions: () => 0, + isScopeFenced: () => true }, scope([repoKey]) ) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts index a2a9d12cb21..95bc4edeed8 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts @@ -2,9 +2,11 @@ * After quiesce, proof that no terminal of a moved workspace still runs on this desktop's daemon * (mover design, section 3.3). The local daemon can prove exit, so only a census it answered can * say `exited`; one it could not answer is `unverifiable`, never a guess (AGENTS.md: SSH Use Case). + * + * Precondition: the move's fence is up, so no new terminal can start in scope while this reads. */ import { parsePtySessionId, WORKTREE_ID_SEPARATOR } from '../../../shared/pty-session-id-format' -import { parseWslUncPath } from '../../../shared/wsl-paths' +import { foldWslUncPathCaseInsensitiveParts, toWindowsWslUncPath } from '../../../shared/wsl-paths' import type { SessionInfo } from '../../daemon/types' export type WslMoveExitProof = @@ -12,25 +14,43 @@ export type WslMoveExitProof = | { verdict: 'live'; sessionIds: string[] } | { verdict: 'unverifiable'; reason: string } +type CensusSession = Pick + export type WslMoveExitProofSources = { /** `listLiveDaemonSessions`: null unless every daemon generation answered. */ - listSessions: () => Promise[] | null> + listSessions: () => Promise /** In-process fallback PTYs from degraded mode, which no daemon census lists. */ countFallbackSessions: () => number + /** The precondition: new terminals in scope are refused. */ + isScopeFenced: () => boolean } +export type WslMoveExitProofScope = { + /** The moving distro; Linux-spelled paths are read inside it. */ + distro: string + /** Workspace keys as session ids mint them: `::` or `folder:`. */ + workspaceKeys: ReadonlySet + /** The ids the moved panes recorded; covers a session whose id predates the minted format. */ + sessionIds: ReadonlySet +} + +type Attribution = 'in-scope' | 'elsewhere' | 'unknown' + export async function proveWslMoveTerminalsExited( sources: WslMoveExitProofSources, - scope: { - /** Workspace keys as session ids mint them: `::` or `folder:`. */ - workspaceKeys: ReadonlySet - /** The ids the moved panes recorded; covers a session whose id predates the minted format. */ - sessionIds: ReadonlySet - } + scope: WslMoveExitProofScope ): Promise { - let sessions: readonly Pick[] | null + let sessions: readonly CensusSession[] | null + let fallbackSessions: number try { + if (!sources.isScopeFenced()) { + return { verdict: 'unverifiable', reason: 'new terminals are not fenced for the move' } + } sessions = await sources.listSessions() + fallbackSessions = sources.countFallbackSessions() + if (!sources.isScopeFenced()) { + return { verdict: 'unverifiable', reason: 'the move fence lifted during the census' } + } } catch (error) { return { verdict: 'unverifiable', @@ -40,34 +60,114 @@ export async function proveWslMoveTerminalsExited( if (sessions === null) { return { verdict: 'unverifiable', reason: 'a daemon generation did not answer' } } - if (sources.countFallbackSessions() > 0) { + if (fallbackSessions > 0) { return { verdict: 'unverifiable', reason: 'terminals are running outside the daemon' } } - const scopeKeys = new Set([...scope.workspaceKeys].map(comparableWorkspaceKey)) - const live = sessions - .filter((session) => { - if (!session.isAlive) { - return false + const { distro } = scope + const scopeKeys = new Set([...scope.workspaceKeys].map((key) => comparableKey(key, distro))) + const roots = [...scope.workspaceKeys].flatMap((key) => { + const path = keyPath(key) + const root = path === null ? null : comparableWslPath(path, distro) + return root === null ? [] : [root] + }) + const live: string[] = [] + const unattributed: string[] = [] + for (const session of sessions) { + if (!session.isAlive) { + continue + } + const attribution = attributeSession(session, scope, scopeKeys, roots) + if (attribution === 'in-scope') { + live.push(session.sessionId) + } else if (attribution === 'unknown') { + unattributed.push(session.sessionId) + } + } + if (live.length > 0) { + return { verdict: 'live', sessionIds: live } + } + return unattributed.length > 0 + ? { + verdict: 'unverifiable', + reason: `live terminals with no known workspace: ${unattributed.join(', ')}` } - if (scope.sessionIds.has(session.sessionId)) { - return true - } - const { worktreeId } = parsePtySessionId(session.sessionId) - return worktreeId !== null && scopeKeys.has(comparableWorkspaceKey(worktreeId)) - }) - .map((session) => session.sessionId) - return live.length > 0 ? { verdict: 'live', sessionIds: live } : { verdict: 'exited' } + : { verdict: 'exited' } } -// `\\wsl$` and `\\wsl.localhost`, and any distro-name case, reach the same folder; the Linux part -// stays case-sensitive. -function comparableWorkspaceKey(key: string): string { +function attributeSession( + session: CensusSession, + scope: WslMoveExitProofScope, + scopeKeys: ReadonlySet, + roots: readonly string[] +): Attribution { + if (scope.sessionIds.has(session.sessionId)) { + return 'in-scope' + } + const { worktreeId } = parsePtySessionId(session.sessionId) + if (worktreeId !== null) { + if (scopeKeys.has(comparableKey(worktreeId, scope.distro))) { + return 'in-scope' + } + // Another workspace's shell can still have changed into a moved folder. + return attributeByCwd(session, scope.distro, roots) === 'in-scope' ? 'in-scope' : 'elsewhere' + } + return attributeByCwd(session, scope.distro, roots) +} + +// A session whose id names no workspace: its folder is the only evidence of where it runs. +function attributeByCwd( + session: CensusSession, + distro: string, + roots: readonly string[] +): Attribution { + const { cwd, wslDistro } = session + if (!cwd) { + return 'unknown' + } + const inDistro = typeof wslDistro === 'string' && sameDistro(wslDistro, distro) + const path = comparableWslPath(cwd, distro) + const underRoot = path !== null && roots.some((root) => isAtOrUnder(path, root)) + // A POSIX folder is a Linux path only inside WSL. A Git Bash `/c/...` is not, but a native-tagged + // `wsl.exe` shell's may be, so only a folder clear of the move rules it out. + if (isLinuxSpelled(cwd) && !inDistro) { + return wslDistro === undefined || (wslDistro === null && underRoot) ? 'unknown' : 'elsewhere' + } + if (underRoot) { + return 'in-scope' + } + const distroRoot = comparableWslPath('/', distro) + const pathInDistro = path !== null && distroRoot !== null && isAtOrUnder(path, distroRoot) + return inDistro || pathInDistro ? 'unknown' : 'elsewhere' +} + +function keyPath(key: string): string | null { const separator = key.indexOf(WORKTREE_ID_SEPARATOR) - if (separator <= 0) { + return separator <= 0 ? null : key.slice(separator + WORKTREE_ID_SEPARATOR.length) +} + +// One spelling per workspace across UNC aliases, distro case, Linux spelling, and trailing separators. +function comparableKey(key: string, distro: string): string { + const path = keyPath(key) + const comparable = path === null ? null : comparableWslPath(path, distro) + if (comparable === null) { return key } - const unc = parseWslUncPath(key.slice(separator + WORKTREE_ID_SEPARATOR.length)) - return unc - ? `${key.slice(0, separator)}${WORKTREE_ID_SEPARATOR}wsl:${unc.distro.toLowerCase()}:${unc.linuxPath}` - : key + return `${key.slice(0, key.indexOf(WORKTREE_ID_SEPARATOR))}${WORKTREE_ID_SEPARATOR}${comparable}` +} + +function comparableWslPath(path: string, distro: string): string | null { + const unc = isLinuxSpelled(path) ? toWindowsWslUncPath(path, distro) : path + return foldWslUncPathCaseInsensitiveParts(unc)?.replace(/\/+$/, '') ?? null +} + +function isLinuxSpelled(path: string): boolean { + return /^\/(?!\/)/.test(path) +} + +function isAtOrUnder(path: string, root: string): boolean { + return path === root || path.startsWith(`${root}/`) +} + +function sameDistro(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase() } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts index e6ad2e5d923..331a432c021 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.test.ts @@ -23,6 +23,7 @@ const idlePane: WslMovePaneFacts = { const hints = { hints: [{ paneKey: 'tab-1:leaf-1', cwd: '/home/dev/repo-1' }], unreadablePaneKeys: [], + noCwdPaneKeys: [], staleCwdPaneKeys: [] } @@ -57,9 +58,15 @@ function harness(overrides: Partial = {}) { describe('quiesceWslMove', () => { it('fences, sleeps, proves exit and records the proven sessions', async () => { - const { deps, journal, removed, steps } = harness() + const proveExited = vi.fn(async (): Promise => ({ verdict: 'exited' })) + const { deps, journal, removed, steps } = harness({ proveExited }) const result = await quiesceWslMove(cutover(), scope, deps) + expect(proveExited).toHaveBeenCalledWith({ + distro: cutover().manifest.source.distro, + workspaceKeys: scope.workspaceKeys, + sessionIds: new Set(['session-1']) + }) expect(journal).toEqual(['source-fenced', 'quiesced']) expect(steps).toEqual(['sleep after source-fenced']) expect(removed).toEqual([]) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts index 5ad37b3e747..3b394d1e4d3 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-quiesce.ts @@ -7,8 +7,8 @@ * their ordinary sleeping state, and waking them later restores them from it, so nothing is lost. */ import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' -import type { WslMoveExitProof } from './wsl-move-exit-proof' -import type { WslMoveRestoreHintCapture } from './wsl-move-restore-hints' +import type { WslMoveExitProof, WslMoveExitProofScope } from './wsl-move-exit-proof' +import type { WslMoveRestoreHintCapture, WslMoveRestoreHintPane } from './wsl-move-restore-hints' import { assessWslMoveTerminals, type WslMoveGateBlocker, @@ -20,8 +20,8 @@ export type WslMoveQuiesceScope = { workspaceIds: readonly string[] /** Workspace keys as session ids mint them, for the exit proof. */ workspaceKeys: ReadonlySet - /** The panes the move carries, with the daemon session each one recorded. */ - panes: readonly { paneKey: string; sessionId: string }[] + /** The panes the move carries, with each one's daemon session and its distro read before Sleep. */ + panes: readonly WslMoveRestoreHintPane[] } export type WslMoveQuiesceDeps = { @@ -34,10 +34,7 @@ export type WslMoveQuiesceDeps = { panes: WslMoveQuiesceScope['panes'], distro: string ) => Promise - proveExited: (scope: { - workspaceKeys: ReadonlySet - sessionIds: ReadonlySet - }) => Promise + proveExited: (scope: WslMoveExitProofScope) => Promise writeCutover: (cutover: WslMoveCutover) => void removeCutover: (migrationId: string) => void now: () => Date @@ -186,7 +183,7 @@ async function proveAndRecord( const restoreHints = await deps.captureRestoreHints(scope.panes, distro) const sessionIds = new Set(scope.panes.map((pane) => pane.sessionId)) // Last, so a session that came back while the hints were read still refuses. - const proof = await deps.proveExited({ workspaceKeys: scope.workspaceKeys, sessionIds }) + const proof = await deps.proveExited({ distro, workspaceKeys: scope.workspaceKeys, sessionIds }) if (proof.verdict === 'live') { return refuse({ reason: 'terminals-live', liveSessionIds: proof.sessionIds }) } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts index fd94a02871d..ff6c12fa398 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest' import type { ColdRestoreDetection } from '../../daemon/history-reader' import { captureWslMoveRestoreHints } from './wsl-move-restore-hints' -const restored = (cwd: string, hasUnreadableRecovery = false): ColdRestoreDetection => ({ +const restored = ( + cwd: string, + hasUnreadableRecovery = false, + cwdFromSpawn = false +): ColdRestoreDetection => ({ status: 'restored', sessionId: 's', hasUnreadableRecovery, + ...(cwdFromSpawn ? { cwdFromSpawn: true as const } : {}), restoreInfo: { snapshotAnsi: '', scrollbackAnsi: '', @@ -44,7 +49,8 @@ describe('captureWslMoveRestoreHints', () => { }, ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((leaf) => ({ paneKey: `t:${leaf}`, - sessionId: `wt@@${leaf}` + sessionId: `wt@@${leaf}`, + wslDistro: 'ubuntu' })), 'Ubuntu' ) @@ -56,9 +62,62 @@ describe('captureWslMoveRestoreHints', () => { { paneKey: 't:h', cwd: '/home/dev/app/bin' } ], unreadablePaneKeys: ['t:f'], + noCwdPaneKeys: ['t:c', 't:d', 't:e'], staleCwdPaneKeys: ['t:h'] }) // A sleep ends sessions cleanly; the read must not skip them for it. expect(seen[0]).toEqual({ ignoreCleanEnd: true, wslDistro: 'Ubuntu' }) }) + + it('reports panes without a usable folder instead of skipping them', async () => { + const answers: Record = { + 'wt@@none': { status: 'none' }, + 'wt@@debian': restored('\\\\wsl.localhost\\Debian\\home\\dev'), + 'wt@@gitbash': restored('/c/src/app'), + 'wt@@unknown': restored('/home/dev/app') + } + const capture = await captureWslMoveRestoreHints( + { detectColdRestoreState: async (sessionId) => answers[sessionId] ?? { status: 'none' } }, + [ + { paneKey: 't:none', sessionId: 'wt@@none', wslDistro: 'Ubuntu' }, + { paneKey: 't:debian', sessionId: 'wt@@debian', wslDistro: 'Ubuntu' }, + // A native Git Bash pane's POSIX folder is not a Linux path in the distro. + { paneKey: 't:gitbash', sessionId: 'wt@@gitbash', wslDistro: null }, + { paneKey: 't:unknown', sessionId: 'wt@@unknown' } + ], + 'Ubuntu' + ) + expect(capture.hints).toEqual([]) + expect(capture.noCwdPaneKeys).toEqual(['t:none', 't:debian', 't:gitbash', 't:unknown']) + }) + + it('names a pane whose read throws as unreadable and reads the rest', async () => { + const capture = await captureWslMoveRestoreHints( + { + detectColdRestoreState: async (sessionId) => { + if (sessionId === 'wt@@bad') { + throw new Error('EIO') + } + return restored('/home/dev/app') + } + }, + [ + { paneKey: 't:bad', sessionId: 'wt@@bad', wslDistro: 'Ubuntu' }, + { paneKey: 't:ok', sessionId: 'wt@@ok', wslDistro: 'Ubuntu' } + ], + 'Ubuntu' + ) + expect(capture.unreadablePaneKeys).toEqual(['t:bad']) + expect(capture.hints).toEqual([{ paneKey: 't:ok', cwd: '/home/dev/app' }]) + }) + + it('flags a folder that is only where the session started as stale', async () => { + const capture = await captureWslMoveRestoreHints( + { detectColdRestoreState: async () => restored('/home/dev/app', false, true) }, + [{ paneKey: 't:a', sessionId: 'wt@@a', wslDistro: 'Ubuntu' }], + 'Ubuntu' + ) + expect(capture.hints).toEqual([{ paneKey: 't:a', cwd: '/home/dev/app' }]) + expect(capture.staleCwdPaneKeys).toEqual(['t:a']) + }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts index 398095fa49f..8808f2b73b7 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts @@ -8,11 +8,20 @@ import { wslUncPathToLinux } from './wsl-move-path-rewriter' export type WslMoveRestoreHint = { paneKey: string; cwd: string } +export type WslMoveRestoreHintPane = { + paneKey: string + sessionId: string + /** The distro the pane's session ran in, as the daemon reported it; null for a native shell. */ + wslDistro?: string | null +} + export type WslMoveRestoreHintCapture = { hints: WslMoveRestoreHint[] /** Panes whose checkpoint could not be read; the preview names them, the move still runs. */ unreadablePaneKeys: string[] - /** Panes restored from a checkpoint whose newer log was unreadable, so the folder may be older. */ + /** Panes with no folder inside the moved distro to restore; they reopen at the workspace root. */ + noCwdPaneKeys: string[] + /** Panes whose folder may be older than the last one the shell was in. */ staleCwdPaneKeys: string[] } @@ -25,37 +34,56 @@ type ColdRestoreReader = { export async function captureWslMoveRestoreHints( reader: ColdRestoreReader, - panes: readonly { paneKey: string; sessionId: string }[], + panes: readonly WslMoveRestoreHintPane[], distro: string ): Promise { const capture: WslMoveRestoreHintCapture = { hints: [], unreadablePaneKeys: [], + noCwdPaneKeys: [], staleCwdPaneKeys: [] } for (const pane of panes) { - // A sleep ends the session cleanly; its checkpoint is exactly what a cold restore would read. - const detection = await reader.detectColdRestoreState(pane.sessionId, { - ignoreCleanEnd: true, - wslDistro: distro - }) + let detection: ColdRestoreDetection + try { + // A sleep ends the session cleanly; its checkpoint is exactly what a cold restore would read. + detection = await reader.detectColdRestoreState(pane.sessionId, { + ignoreCleanEnd: true, + wslDistro: distro + }) + } catch { + capture.unreadablePaneKeys.push(pane.paneKey) + continue + } if (detection.status === 'unreadable') { capture.unreadablePaneKeys.push(pane.paneKey) continue } if (detection.status !== 'restored') { + capture.noCwdPaneKeys.push(pane.paneKey) continue } - if (detection.hasUnreadableRecovery) { - capture.staleCwdPaneKeys.push(pane.paneKey) + const linux = hostLinuxCwd(detection.restoreInfo.cwd, pane, distro) + if (!linux) { + capture.noCwdPaneKeys.push(pane.paneKey) + continue } - // The checkpoint holds the bridge's UNC spelling or the shell's own Linux path; only a path - // inside the moved distro means anything on the host. - const { cwd } = detection.restoreInfo - const linux = wslUncPathToLinux(cwd, distro) ?? (/^\/(?!\/)/.test(cwd) ? cwd : null) - if (linux) { - capture.hints.push({ paneKey: pane.paneKey, cwd: linux }) + capture.hints.push({ paneKey: pane.paneKey, cwd: linux }) + // A spawn-folder fallback is as stale as a checkpoint behind an unreadable log. + if (detection.hasUnreadableRecovery || detection.cwdFromSpawn) { + capture.staleCwdPaneKeys.push(pane.paneKey) } } return capture } + +// The bridge's UNC spelling, or a Linux path from a session known to run in the moved distro. +function hostLinuxCwd(cwd: string, pane: WslMoveRestoreHintPane, distro: string): string | null { + const fromUnc = wslUncPathToLinux(cwd, distro) + if (fromUnc) { + return fromUnc + } + const ranInDistro = + typeof pane.wslDistro === 'string' && pane.wslDistro.toLowerCase() === distro.toLowerCase() + return ranInDistro && /^\/(?!\/)/.test(cwd) ? cwd : null +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.test.ts index 7fc396d959f..63d307bfc15 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.test.ts @@ -34,20 +34,20 @@ describe('assessWslMoveTerminals', () => { 'an agent mid-turn', { agent: { state: 'working', resumable: true, ownsForeground: true } }, 'agent-working', - ['wait', 'end'] + ['wait', 'end', 'skip-project'] ], [ 'an agent at a permission prompt', { agent: { state: 'waiting', resumable: true, ownsForeground: true } }, 'agent-working', - ['wait', 'end'] + ['wait', 'end', 'skip-project'] ], [ // Copilot, Pi and Freebuff report a pending ask or approval as 'blocked'. 'an agent reported blocked on a person', { agent: { state: 'blocked', resumable: true, ownsForeground: true } }, 'agent-working', - ['wait', 'end'] + ['wait', 'end', 'skip-project'] ], [ 'an agent with no session to resume', @@ -96,12 +96,52 @@ describe('assessWslMoveTerminals', () => { }) }) + it.each([ + ['a done, non-resumable row', { state: 'done', resumable: false, ownsForeground: false }], + ['a working row', { state: 'working', resumable: true, ownsForeground: false }], + ['a waiting row', { state: 'waiting', resumable: false, ownsForeground: false }] + ] as const)('does not let %s left on an idle shell block it', (_label, agent) => { + expect(gate([pane('t1:a', { agent })])).toEqual({ verdict: 'ready', paneKeys: ['t1:a'] }) + }) + + it('still blocks a current non-resumable agent in the foreground', () => { + expect( + gate([ + pane('t1:a', { + foreground: 'process-running', + agent: { state: 'done', resumable: false, ownsForeground: true } + }) + ]) + ).toEqual({ + verdict: 'blocked', + blockers: [{ kind: 'agent-not-resumable', ids: ['t1:a'], actions: ['end', 'skip-project'] }] + }) + }) + + it('reports a pane with an unbounded byte count once, as unverifiable', () => { + expect(gate([pane('t1:a', { scrollbackBytes: Number.POSITIVE_INFINITY })])).toEqual({ + verdict: 'blocked', + blockers: [{ kind: 'unverifiable', ids: ['t1:a'], actions: ['wait', 'skip-project'] }] + }) + }) + + it('counts a pane listed twice once, for the caps and the ready list', () => { + const atCount = Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS }, (_, i) => + pane(`t${i}:a`) + ) + const verdict = gate([...atCount, pane('t0:a')]) + expect(verdict.verdict).toBe('ready') + expect(verdict.verdict === 'ready' && verdict.paneKeys).toHaveLength( + MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + ) + }) + it('blocks on unfinished automation runs and orchestration dispatches in scope', () => { expect(gate([pane('t1:a')], ['run-1'], ['dispatch-1'])).toEqual({ verdict: 'blocked', blockers: [ - { kind: 'automation-running', ids: ['run-1'], actions: ['wait'] }, - { kind: 'dispatch-running', ids: ['dispatch-1'], actions: ['wait'] } + { kind: 'automation-running', ids: ['run-1'], actions: ['wait', 'skip-project'] }, + { kind: 'dispatch-running', ids: ['dispatch-1'], actions: ['wait', 'skip-project'] } ] }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.ts b/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.ts index 9e9267d6113..863112ac43a 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.ts @@ -64,43 +64,50 @@ export function assessWslMoveTerminals(inputs: WslMoveGateInputs): WslMoveGateVe } } for (const pane of inputs.panes) { - const { agent } = pane - if (pane.foreground === 'unverifiable' || !isByteCount(pane.scrollbackBytes)) { + // Only an agent that owns the foreground is current; a retained row speaks for nothing. + const agent = pane.agent?.ownsForeground ? pane.agent : undefined + const hasByteCount = isByteCount(pane.scrollbackBytes) + if (pane.foreground === 'unverifiable' || !hasByteCount) { block('unverifiable', pane.paneKey, ['wait', 'skip-project']) } else if (agent && AGENT_BUSY_STATES.has(agent.state)) { - block('agent-working', pane.paneKey, ['wait', 'end']) - } else if (pane.foreground === 'process-running' && !agent?.ownsForeground) { - block('process-running', pane.paneKey, ['end', 'skip-project']) + block('agent-working', pane.paneKey, ['wait', 'end', 'skip-project']) } else if (agent && !agent.resumable) { block('agent-not-resumable', pane.paneKey, ['end', 'skip-project']) + } else if (pane.foreground === 'process-running' && !agent) { + block('process-running', pane.paneKey, ['end', 'skip-project']) } - if (pane.scrollbackBytes > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { + if (hasByteCount && pane.scrollbackBytes > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { block('scrollback-too-large', pane.paneKey, ['clear-scrollback']) } } for (const id of inputs.runningAutomationRunIds) { - block('automation-running', id, ['wait']) + block('automation-running', id, ['wait', 'skip-project']) } for (const id of inputs.activeDispatchIds) { - block('dispatch-running', id, ['wait']) + block('dispatch-running', id, ['wait', 'skip-project']) + } + // A pane listed twice is one snapshot: count its largest reported size once. + const scrollbackByPane = new Map() + for (const pane of inputs.panes) { + if (isByteCount(pane.scrollbackBytes) && pane.scrollbackBytes > 0) { + const bytes = Math.max(scrollbackByPane.get(pane.paneKey) ?? 0, pane.scrollbackBytes) + scrollbackByPane.set(pane.paneKey, bytes) + } } // Over the transfer's caps is a blocker, never a truncation; reported together with any // per-pane overage so clearing one pane never uncovers a second blocker. - const withScrollback = inputs.panes.filter( - (pane) => isByteCount(pane.scrollbackBytes) && pane.scrollbackBytes > 0 - ) - const total = withScrollback.reduce((sum, pane) => sum + pane.scrollbackBytes, 0) + const total = [...scrollbackByPane.values()].reduce((sum, bytes) => sum + bytes, 0) if ( total > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES || - withScrollback.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + scrollbackByPane.size > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS ) { - for (const pane of withScrollback) { - block('scrollback-too-large', pane.paneKey, ['clear-scrollback']) + for (const paneKey of scrollbackByPane.keys()) { + block('scrollback-too-large', paneKey, ['clear-scrollback']) } } return blockers.size > 0 ? { verdict: 'blocked', blockers: [...blockers.values()] } - : { verdict: 'ready', paneKeys: inputs.panes.map((pane) => pane.paneKey) } + : { verdict: 'ready', paneKeys: [...new Set(inputs.panes.map((pane) => pane.paneKey))] } } function isByteCount(value: number): boolean {