mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user