fix(wsl-mover): match case-folded WSL paths in the exit proof, report panes without a restore folder, ignore stale agent rows (D1b-5b follow-up) (#31211)

* fix(wsl-mover): match case-folded WSL paths in the exit proof, report panes without a restore folder, ignore stale agent rows

* fix(wsl-mover): attribute moved-folder shells by folder, recheck the fence after the census
This commit is contained in:
Neil
2026-10-20 13:45:29 -07:00
committed by GitHub
parent 0a5a7b1446
commit 8a4ef49a4d
11 changed files with 479 additions and 105 deletions
+18
View File
@@ -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,
+14 -22
View File
@@ -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
@@ -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
@@ -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<SessionInfo, 'sessionId' | 'isAlive'>[] | null
type CensusSession = Pick<SessionInfo, 'sessionId' | 'isAlive'> &
Partial<Pick<SessionInfo, 'cwd' | 'wslDistro'>>
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])
)
@@ -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<SessionInfo, 'sessionId' | 'isAlive' | 'cwd' | 'wslDistro'>
export type WslMoveExitProofSources = {
/** `listLiveDaemonSessions`: null unless every daemon generation answered. */
listSessions: () => Promise<readonly Pick<SessionInfo, 'sessionId' | 'isAlive'>[] | null>
listSessions: () => Promise<readonly CensusSession[] | null>
/** 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: `<repoId>::<path>` or `folder:<id>`. */
workspaceKeys: ReadonlySet<string>
/** The ids the moved panes recorded; covers a session whose id predates the minted format. */
sessionIds: ReadonlySet<string>
}
type Attribution = 'in-scope' | 'elsewhere' | 'unknown'
export async function proveWslMoveTerminalsExited(
sources: WslMoveExitProofSources,
scope: {
/** Workspace keys as session ids mint them: `<repoId>::<path>` or `folder:<id>`. */
workspaceKeys: ReadonlySet<string>
/** The ids the moved panes recorded; covers a session whose id predates the minted format. */
sessionIds: ReadonlySet<string>
}
scope: WslMoveExitProofScope
): Promise<WslMoveExitProof> {
let sessions: readonly Pick<SessionInfo, 'sessionId' | 'isAlive'>[] | 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<string>,
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()
}
@@ -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<WslMoveQuiesceDeps> = {}) {
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<WslMoveExitProof> => ({ 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([])
@@ -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<string>
/** 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<WslMoveRestoreHintCapture>
proveExited: (scope: {
workspaceKeys: ReadonlySet<string>
sessionIds: ReadonlySet<string>
}) => Promise<WslMoveExitProof>
proveExited: (scope: WslMoveExitProofScope) => Promise<WslMoveExitProof>
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 })
}
@@ -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<string, ColdRestoreDetection> = {
'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'])
})
})
@@ -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<WslMoveRestoreHintCapture> {
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
}
@@ -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'] }
]
})
})
@@ -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<string, number>()
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 {