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 new file mode 100644 index 00000000000..ca0258543c8 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { SessionInfo } from '../../daemon/types' +import { proveWslMoveTerminalsExited } from './wsl-move-exit-proof' + +type Census = Pick[] | null + +const repoKey = 'repo::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app' +const scope = (workspaceKeys: string[], sessionIds: string[] = []) => ({ + workspaceKeys: new Set(workspaceKeys), + sessionIds: new Set(sessionIds) +}) +const prove = (census: Census, keys = [repoKey], sessionIds: string[] = [], fallback = 0) => + proveWslMoveTerminalsExited( + { listSessions: async () => census, countFallbackSessions: () => fallback }, + scope(keys, sessionIds) + ) + +describe('proveWslMoveTerminalsExited', () => { + it('proves exit when no live session belongs to a moved workspace', async () => { + await expect( + prove([ + { sessionId: `${repoKey}@@1`, isAlive: false }, + { sessionId: 'other::C:\\src@@2', isAlive: true } + ]) + ).resolves.toEqual({ verdict: 'exited' }) + }) + + it('names every live session of a moved workspace', async () => { + const live = `${repoKey}@@3` + await expect(prove([{ sessionId: live, isAlive: true }])).resolves.toEqual({ + verdict: 'live', + sessionIds: [live] + }) + }) + + it.each([ + ['the wsl$ spelling', 'repo::\\\\wsl$\\Ubuntu\\home\\dev\\app@@4'], + ['another distro-name case', 'repo:://wsl.localhost/ubuntu/home/dev/app@@5'] + ])('matches a session minted under %s', async (_label, sessionId) => { + await expect(prove([{ sessionId, isAlive: true }])).resolves.toEqual({ + verdict: 'live', + sessionIds: [sessionId] + }) + }) + + it('keeps the Linux part case-sensitive', async () => { + await expect( + prove([{ sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\dev\\App@@6', isAlive: true }]) + ).resolves.toEqual({ verdict: 'exited' }) + }) + + it('matches a folder workspace by its minted key', async () => { + await expect( + prove([{ sessionId: 'folder:abc@@1', isAlive: true }], ['folder:abc']) + ).resolves.toEqual({ verdict: 'live', sessionIds: ['folder:abc@@1'] }) + }) + + it('counts a live pane session whose id predates the minted format', async () => { + await expect( + prove([{ sessionId: 'legacy-uuid', isAlive: true }], [repoKey], ['legacy-uuid']) + ).resolves.toEqual({ verdict: 'live', sessionIds: ['legacy-uuid'] }) + }) + + it('never reads a census it could not take as exit', async () => { + await expect( + proveWslMoveTerminalsExited( + { + listSessions: async () => { + throw new Error('daemon unreachable') + }, + countFallbackSessions: () => 0 + }, + scope([repoKey]) + ) + ).resolves.toEqual({ verdict: 'unverifiable', reason: 'daemon unreachable' }) + await expect(prove(null)).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('cannot prove exit while terminals run outside the daemon', async () => { + await expect(prove([], [repoKey], [], 1)).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) +}) 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 new file mode 100644 index 00000000000..a2a9d12cb21 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-exit-proof.ts @@ -0,0 +1,73 @@ +/** + * 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). + */ +import { parsePtySessionId, WORKTREE_ID_SEPARATOR } from '../../../shared/pty-session-id-format' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import type { SessionInfo } from '../../daemon/types' + +export type WslMoveExitProof = + | { verdict: 'exited' } + | { verdict: 'live'; sessionIds: string[] } + | { verdict: 'unverifiable'; reason: string } + +export type WslMoveExitProofSources = { + /** `listLiveDaemonSessions`: null unless every daemon generation answered. */ + listSessions: () => Promise[] | null> + /** In-process fallback PTYs from degraded mode, which no daemon census lists. */ + countFallbackSessions: () => number +} + +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 + } +): Promise { + let sessions: readonly Pick[] | null + try { + sessions = await sources.listSessions() + } catch (error) { + return { + verdict: 'unverifiable', + reason: error instanceof Error ? error.message : String(error) + } + } + if (sessions === null) { + return { verdict: 'unverifiable', reason: 'a daemon generation did not answer' } + } + if (sources.countFallbackSessions() > 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 + } + 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' } +} + +// `\\wsl$` and `\\wsl.localhost`, and any distro-name case, reach the same folder; the Linux part +// stays case-sensitive. +function comparableWorkspaceKey(key: string): string { + const separator = key.indexOf(WORKTREE_ID_SEPARATOR) + if (separator <= 0) { + 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 +} 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 new file mode 100644 index 00000000000..fd94a02871d --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.test.ts @@ -0,0 +1,64 @@ +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 => ({ + status: 'restored', + sessionId: 's', + hasUnreadableRecovery, + restoreInfo: { + snapshotAnsi: '', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd, + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + } + } +}) + +describe('captureWslMoveRestoreHints', () => { + it('keeps each pane its last folder in the Linux form the host opens', async () => { + const answers: Record = { + 'wt@@a': restored('\\\\wsl.localhost\\Ubuntu\\home\\dev\\app\\src'), + 'wt@@b': restored('/home/dev/app/test'), + 'wt@@c': restored('\\\\wsl.localhost\\Debian\\home\\dev'), + 'wt@@d': restored('C:\\Users\\dev'), + 'wt@@e': { status: 'none' }, + 'wt@@f': { status: 'unreadable', sessionId: 'wt@@f' }, + 'wt@@g': restored('\\\\wsl$\\ubuntu\\home\\dev\\app\\docs'), + 'wt@@h': restored('//wsl.localhost/Ubuntu/home/dev/app/bin', true) + } + const seen: unknown[] = [] + const capture = await captureWslMoveRestoreHints( + { + detectColdRestoreState: async (sessionId, opts) => { + seen.push(opts) + return answers[sessionId] ?? { status: 'none' } + } + }, + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((leaf) => ({ + paneKey: `t:${leaf}`, + sessionId: `wt@@${leaf}` + })), + 'Ubuntu' + ) + expect(capture).toEqual({ + hints: [ + { paneKey: 't:a', cwd: '/home/dev/app/src' }, + { paneKey: 't:b', cwd: '/home/dev/app/test' }, + { paneKey: 't:g', cwd: '/home/dev/app/docs' }, + { paneKey: 't:h', cwd: '/home/dev/app/bin' } + ], + unreadablePaneKeys: ['t:f'], + staleCwdPaneKeys: ['t:h'] + }) + // A sleep ends sessions cleanly; the read must not skip them for it. + expect(seen[0]).toEqual({ ignoreCleanEnd: true, wslDistro: 'Ubuntu' }) + }) +}) 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 new file mode 100644 index 00000000000..398095fa49f --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts @@ -0,0 +1,61 @@ +/** + * Each paused pane's last working directory, read from the daemon's own checkpoint after quiesce + * (mover design, section 3.3). It lives nowhere else: without it a pane would reopen on the host + * at its worktree root, which is a silent loss. + */ +import type { ColdRestoreDetection } from '../../daemon/history-reader' +import { wslUncPathToLinux } from './wsl-move-path-rewriter' + +export type WslMoveRestoreHint = { paneKey: string; cwd: string } + +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. */ + staleCwdPaneKeys: string[] +} + +type ColdRestoreReader = { + detectColdRestoreState: ( + sessionId: string, + opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } + ) => Promise +} + +export async function captureWslMoveRestoreHints( + reader: ColdRestoreReader, + panes: readonly { paneKey: string; sessionId: string }[], + distro: string +): Promise { + const capture: WslMoveRestoreHintCapture = { + hints: [], + unreadablePaneKeys: [], + 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 + }) + if (detection.status === 'unreadable') { + capture.unreadablePaneKeys.push(pane.paneKey) + continue + } + if (detection.status !== 'restored') { + continue + } + if (detection.hasUnreadableRecovery) { + capture.staleCwdPaneKeys.push(pane.paneKey) + } + // 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 }) + } + } + return capture +} 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 new file mode 100644 index 00000000000..6878335c783 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, + MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES +} from '../../../shared/orcad-migration-scrollback' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' +import { assessWslMoveTerminals, type WslMovePaneFacts } from './wsl-move-terminal-gate' + +const pane = (paneKey: string, extra: Partial = {}): WslMovePaneFacts => ({ + paneKey, + foreground: 'shell-idle', + scrollbackBytes: 1024, + ...extra +}) +const gate = (panes: WslMovePaneFacts[], runs: string[] = [], dispatches: string[] = []) => + assessWslMoveTerminals({ panes, runningAutomationRunIds: runs, activeDispatchIds: dispatches }) + +describe('assessWslMoveTerminals', () => { + it('lets idle shells and resumable settled agents through', () => { + expect( + gate([ + pane('t1:a'), + pane('t1:b', { agent: { state: 'done', resumable: true, ownsForeground: false } }), + pane('t2:a', { + agent: { state: 'done', resumable: true, ownsForeground: true }, + foreground: 'process-running' + }) + ]) + ).toEqual({ verdict: 'ready', paneKeys: ['t1:a', 't1:b', 't2:a'] }) + }) + + it.each([ + [ + 'an agent mid-turn', + { agent: { state: 'working', resumable: true, ownsForeground: true } }, + 'agent-working', + ['wait', 'end'] + ], + [ + 'an agent at a permission prompt', + { agent: { state: 'waiting', resumable: true, ownsForeground: true } }, + 'agent-working', + ['wait', 'end'] + ], + [ + // 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'] + ], + [ + 'an agent with no session to resume', + { agent: { state: 'done', resumable: false, ownsForeground: true } }, + 'agent-not-resumable', + ['end', 'skip-project'] + ], + ['a dev server', { foreground: 'process-running' }, 'process-running', ['end', 'skip-project']], + [ + 'a dev server under a stale agent row', + { + foreground: 'process-running', + agent: { state: 'done', resumable: true, ownsForeground: false } + }, + 'process-running', + ['end', 'skip-project'] + ], + [ + 'a pane the daemon cannot answer for', + { foreground: 'unverifiable' }, + 'unverifiable', + ['wait', 'skip-project'] + ], + [ + 'a pane with no byte count', + { scrollbackBytes: Number.NaN }, + 'unverifiable', + ['wait', 'skip-project'] + ], + [ + 'a pane with a negative byte count', + { scrollbackBytes: -1 }, + 'unverifiable', + ['wait', 'skip-project'] + ], + [ + 'a pane over the scrollback cap', + { scrollbackBytes: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 }, + 'scrollback-too-large', + ['clear-scrollback'] + ] + ] as const)('blocks %s, with the actions that unblock it', (_label, facts, kind, actions) => { + expect(gate([pane('t1:a', facts)])).toEqual({ + verdict: 'blocked', + blockers: [{ kind, ids: ['t1:a'], actions }] + }) + }) + + 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'] } + ] + }) + }) + + it('blocks rather than truncates when the move would exceed the transfer caps', () => { + const many = Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + 1 }, (_, i) => + pane(`t${i}:a`) + ) + expect(gate(many)).toMatchObject({ + verdict: 'blocked', + blockers: [{ kind: 'scrollback-too-large' }] + }) + const perPane = Math.ceil(MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES / 60) + const big = Array.from({ length: 61 }, (_, i) => pane(`b${i}:a`, { scrollbackBytes: perPane })) + expect(gate(big)).toMatchObject({ + verdict: 'blocked', + blockers: [{ kind: 'scrollback-too-large' }] + }) + }) + + it('lets a move exactly at the transfer caps through', () => { + const atCount = Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS }, (_, i) => + pane(`t${i}:a`) + ) + expect(gate(atCount).verdict).toBe('ready') + const perPane = MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES / 64 + const atTotal = Array.from({ length: 64 }, (_, i) => + pane(`b${i}:a`, { scrollbackBytes: perPane }) + ) + expect(gate(atTotal).verdict).toBe('ready') + }) + + it('reports a pane over its own cap together with a move over the total cap', () => { + const over = TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 + const panes = [ + pane('huge:a', { scrollbackBytes: over }), + ...Array.from({ length: 52 }, (_, i) => + pane(`b${i}:a`, { scrollbackBytes: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT }) + ) + ] + const verdict = gate(panes) + expect(verdict.verdict).toBe('blocked') + const blocker = verdict.verdict === 'blocked' ? verdict.blockers[0] : undefined + expect(blocker?.kind).toBe('scrollback-too-large') + expect(blocker?.ids).toHaveLength(53) + expect(new Set(blocker?.ids).size).toBe(53) + }) +}) 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 new file mode 100644 index 00000000000..36a55decb24 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-terminal-gate.ts @@ -0,0 +1,108 @@ +/** + * The WSL move's terminal gate (mover design, section 3.2): every pane in scope is either safe to + * pause and restore on the host, or it blocks the move with the actions that would unblock it. + * Nothing is ever ended to make progress. Unlike SSH, the local daemon can prove exit, so a pane it + * cannot answer for is `unverifiable` and blocks rather than being guessed at. + */ +import type { AgentStatusState } from '../../../shared/agent-status-types' +import { + MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, + MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES +} from '../../../shared/orcad-migration-scrollback' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' + +export type WslMovePaneFacts = { + paneKey: string + /** The daemon's foreground-process probe, the one the close-tab warning uses. */ + foreground: 'shell-idle' | 'process-running' | 'unverifiable' + /** + * The agent in the pane, if any, as the hook server reports it. `ownsForeground` is true only + * when the foreground process is that agent; a stale row must not vouch for a dev server. + */ + agent?: { state: AgentStatusState; resumable: boolean; ownsForeground: boolean } + scrollbackBytes: number +} + +export type WslMoveGateInputs = { + panes: readonly WslMovePaneFacts[] + /** Automation runs in scope that have not finished. */ + runningAutomationRunIds: readonly string[] + /** Orchestration dispatches in scope that have not finished. */ + activeDispatchIds: readonly string[] +} + +export type WslMoveGateAction = 'wait' | 'end' | 'skip-project' | 'clear-scrollback' + +export type WslMoveGateBlocker = { + kind: + | 'agent-working' + | 'process-running' + | 'agent-not-resumable' + | 'unverifiable' + | 'automation-running' + | 'dispatch-running' + | 'scrollback-too-large' + ids: string[] + actions: WslMoveGateAction[] +} + +export type WslMoveGateVerdict = + | { verdict: 'ready'; paneKeys: string[] } + | { verdict: 'blocked'; blockers: WslMoveGateBlocker[] } + +// Mid-turn or waiting on a person: providers report a pending prompt as 'waiting' or 'blocked'. +const AGENT_BUSY_STATES: ReadonlySet = new Set(['working', 'waiting', 'blocked']) + +export function assessWslMoveTerminals(inputs: WslMoveGateInputs): WslMoveGateVerdict { + const blockers = new Map() + const block = (kind: WslMoveGateBlocker['kind'], id: string, actions: WslMoveGateAction[]) => { + const existing = blockers.get(kind) + if (!existing) { + blockers.set(kind, { kind, ids: [id], actions }) + } else if (!existing.ids.includes(id)) { + existing.ids.push(id) + } + } + for (const pane of inputs.panes) { + const { agent } = pane + if (pane.foreground === 'unverifiable' || !isByteCount(pane.scrollbackBytes)) { + 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']) + } else if (agent && !agent.resumable) { + block('agent-not-resumable', pane.paneKey, ['end', 'skip-project']) + } + if (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']) + } + for (const id of inputs.activeDispatchIds) { + block('dispatch-running', id, ['wait']) + } + // 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) + if ( + total > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES || + withScrollback.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + ) { + for (const pane of withScrollback) { + block('scrollback-too-large', pane.paneKey, ['clear-scrollback']) + } + } + return blockers.size > 0 + ? { verdict: 'blocked', blockers: [...blockers.values()] } + : { verdict: 'ready', paneKeys: inputs.panes.map((pane) => pane.paneKey) } +} + +function isByteCount(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0 +}