feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d) (#28820)

* feat(wsl-mover): pause a moving project's terminals through Sleep and prove they exited (D1b-5d)

* fix(wsl-mover): gate again under the fence, swap journal phases atomically, keep sessions frozen through commit (D1b-5d)

* test(wsl-mover): the fence e2e walks the journal one phase at a time, as a move writes it (D1b-5d)
This commit is contained in:
Neil
2026-10-14 13:07:50 -07:00
committed by GitHub
parent 5976c4628d
commit 8f83f6b076
37 changed files with 1306 additions and 100 deletions
@@ -6,6 +6,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts',
'src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts',
'src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts',
'src/main/persistence/moving-wsl-projects/wsl-move-session-freeze.test.ts',
'src/main/acp/acp-structured-host-live-listing.test.ts',
'src/main/acp/acp-structured-host-configured-default.test.ts',
'src/main/pi/rpc-configured-default.test.ts',
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { wslMoveCutoverFixture as cutover } from '../../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import {
WslMoveProjectFencedError,
loadWslMoveFence,
@@ -24,7 +24,7 @@ const accept = (worktreeId: string, sessionId?: string) =>
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-spawn-'))
loadWslMoveFence(userDataPath)
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
listLiveDaemonPtyIds.mockReset()
})
afterEach(() => {
@@ -7,7 +7,7 @@ import { closeTestStores, createStore, testState } from '../../persistence-test-
import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import {
WslMoveProjectFencedError,
loadWslMoveFence,
@@ -83,7 +83,7 @@ describe('project host setups under a WSL move', () => {
it('refuses IPC edits to a local setup of a moving repo and leaves others alone', async () => {
store.addRepo(repo('repo-1'))
store.addRepo(repo('repo-2'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
registerProjectHostSetupHandlers(new BrowserWindow(), store)
const moving = setupFor('repo-1')
@@ -107,7 +107,7 @@ describe('project host setups under a WSL move', () => {
it("keeps a setup's own repo frozen once the WSL host owns it", async () => {
store.addRepo(repo('repo-1'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
registerProjectHostSetupHandlers(new BrowserWindow(), store)
const owned = setupFor('repo-1')
@@ -121,7 +121,7 @@ describe('project host setups under a WSL move', () => {
it('refuses a runtime setup delete for a moving repo', () => {
store.addRepo(repo('repo-1'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
const controller = new RuntimeProjectHostSetupController({
getStore: () => store,
listRepos: () => store.getRepos(),
@@ -145,7 +145,7 @@ describe('project host setups under a WSL move', () => {
describe('projects:update once the WSL host owns a repo', () => {
it('lets project-wide settings change again', async () => {
store.addRepo(repo('repo-1'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
registerRepoCatalogHandlers(new BrowserWindow(), store)
const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes('repo-1'))
const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } }
@@ -159,7 +159,7 @@ describe('projects:update once the WSL host owns a repo', () => {
describe('issue command writes under a WSL move', () => {
it('refuses a runtime write to a moving repo before touching the file', async () => {
store.addRepo(repo('repo-1'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
const write = vi.spyOn(issueCommandFile, 'writeIssueCommand')
const commands = new RuntimeRepositoryIssueCommand({
resolveRepo: async (id) => repo(id),
@@ -174,7 +174,7 @@ describe('issue command writes under a WSL move', () => {
describe('repo settings on another host under a WSL move', () => {
it('leaves a same-id repo row on another host writable', async () => {
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
const onHost = repo('repo-1', { executionHostId: 'runtime:wsl:ubuntu' })
vi.spyOn(store, 'updateRepo').mockReturnValue(onHost)
const controller = new RuntimeRepositorySettingsController({
@@ -7,7 +7,7 @@ import { closeTestStores, createStore, testState } from '../../persistence-test-
import type { Store } from '../../persistence'
import type { Repo } from '../../../shared/repo-types'
import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import {
WslMoveProjectFencedError,
loadWslMoveFence,
@@ -74,7 +74,7 @@ describe('folderWorkspaces:update under a WSL move', () => {
createdFrom: 'folder-scan'
})
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'notes' })
writeWslMoveCutover(
seedWslMoveCutover(
testState.dir,
cutover('move-1', 'source-fenced', {}, { folderWorkspaceIds: [workspace.id] })
)
@@ -111,7 +111,7 @@ describe('projects:update under a WSL move', () => {
it('refuses a runtime preference change when a source repo is moving', async () => {
store.addRepo(repo('repo-1'))
store.addRepo(repo('repo-2'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
registerRepoCatalogHandlers(new BrowserWindow(), store)
const projectFor = (repoId: string) =>
store.getProjects().find((project) => project.sourceRepoIds.includes(repoId))
@@ -129,7 +129,7 @@ describe('projects:update under a WSL move', () => {
describe('RuntimeRepositorySettingsController under a WSL move', () => {
it('refuses a settings edit to a moving repo and writes nothing', async () => {
store.addRepo(repo('repo-1'))
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
const notifyReposChanged = vi.fn()
const controller = new RuntimeRepositorySettingsController({
getStore: () => store,
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import {
WslMoveProjectFencedError,
loadWslMoveFence,
@@ -53,7 +53,7 @@ beforeEach(() => {
setupWorktreeHandlers()
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-worktree-ipc-'))
loadWslMoveFence(userDataPath)
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
})
afterEach(() => {
resetWslMoveFenceForTests()
@@ -0,0 +1,15 @@
/** Writes a move's record through every phase up to its own, as the journal requires. */
import {
WSL_MOVE_CUTOVER_PHASES,
type WslMoveCutover
} from '../../../../shared/wsl-move/wsl-move-cutover'
import { writeWslMoveCutover } from '../../moving-wsl-projects/wsl-move-cutover-journal'
export function seedWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void {
for (const phase of WSL_MOVE_CUTOVER_PHASES) {
writeWslMoveCutover(userDataPath, { ...cutover, phase })
if (phase === cutover.phase) {
return
}
}
}
@@ -1,3 +1,4 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import type { GhAccountBinding } from '../../../shared/github/account-binding'
@@ -27,6 +28,17 @@ import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import { pruneDeregisteredRepoUiResidue } from './repo-lifecycle-ui-residue'
import { isWslMoveProjectSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
// A paused WSL move keeps its local rows for a downgrade.
function removeLocalRepoSession(
session: WorkspaceSessionState,
repoId: string
): WorkspaceSessionState {
return isWslMoveProjectSessionFrozen({ repoId })
? session
: removeRepoFromWorkspaceSession(session, repoId)
}
type RepoLifecycleOperationsRuntime = Pick<
StoreRuntimeState,
| 'gitUsernameCache'
@@ -83,11 +95,10 @@ export class RepoLifecycleOperations {
syncProjectHostSetupCompatibilityState(this)
pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null)
if (!idStillPresent) {
this[repoLifecycleOperationsContext].runtime.state.workspaceSession =
removeRepoFromWorkspaceSession(
this[repoLifecycleOperationsContext].runtime.state.workspaceSession,
id
)
this[repoLifecycleOperationsContext].runtime.state.workspaceSession = removeLocalRepoSession(
this[repoLifecycleOperationsContext].runtime.state.workspaceSession,
id
)
this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId =
removeRepoFromHostWorkspaceSessions(
this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId,
@@ -121,7 +132,7 @@ export class RepoLifecycleOperations {
for (const repoId of orphanRepoIds) {
retireLocalWorktreeScanGeneration(repoId)
pruneWorktreeStateForRepo(this, repoId, null)
state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId)
state.workspaceSession = removeLocalRepoSession(state.workspaceSession, repoId)
state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
state.workspaceSessionsByHostId,
repoId
@@ -17,6 +17,7 @@ import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-author
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration'
import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate'
import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
import {
removeWorkspaceSessionOwner,
workspaceSessionPartitionIdsForHost
@@ -144,6 +145,10 @@ export function removeWorkspaceSessionOwnerInPartition(
if (!hasPersistedWorkspaceSession(owner, resolved)) {
return
}
// A paused WSL move keeps its local rows for a downgrade.
if (resolved === LOCAL_EXECUTION_HOST_ID && isWslMoveWorkspaceSessionFrozen(worktreeId)) {
return
}
const current = owner.getWorkspaceSession(resolved)
const session = removeWorkspaceSessionOwner(current, worktreeId, {
advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true
@@ -19,6 +19,7 @@ import {
withRequiredWorkspaceSessionMaps
} from './session-host-partitions'
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
import { scheduleSave } from './write-scheduling'
import { orderTerminalRowsByTabGroups } from '../../../shared/workspace-layout/terminal-row-order'
@@ -60,7 +61,12 @@ export class SessionSnapshotOperations {
if (runtime.durableMutationPhase === 'rollback') {
this.assertSnapshotAdmission(true)
// The fieldwise rollback already preserves newer edits; renderer rebasing would undo it.
this.publishSession(session, resolved)
this.publishSession(
resolved === LOCAL_EXECUTION_HOST_ID
? keepWslMoveFrozenSessionsForStore(session, runtime.state.workspaceSession)
: session,
resolved
)
return
}
if (resolved === LOCAL_EXECUTION_HOST_ID) {
@@ -102,6 +108,10 @@ export class SessionSnapshotOperations {
if (Object.hasOwn(patch, 'unifiedTabs')) {
next = dropClosedTerminalTabs(next)
}
if (resolved === LOCAL_EXECUTION_HOST_ID) {
const { state } = this[sessionSnapshotOperationsContext].runtime
next = keepWslMoveFrozenSessionsForStore(next, state.workspaceSession)
}
this.publishSession(next, resolved)
}
@@ -12,6 +12,7 @@ import {
} from '../../terminal-scrollback-snapshot-async-migration'
import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields'
import { dropClosedTerminalTabs } from '../closed-terminal-tab-write-fence'
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization'
import {
normalizeWorkspaceSessionPaneIdentities,
@@ -37,6 +38,7 @@ export function setLocalWorkspaceSession(
): void {
const context = getSessionSnapshotOperationsContext(owner)
const prior = context.runtime.state.workspaceSession
session = keepWslMoveFrozenSessionsForStore(session, prior)
// Why here and not at the callers: the before-unload stage path writes the renderer's payload
// straight through, so a per-caller guard leaves the quit write erasing runtime-authored rows.
session = dropClosedTerminalTabs(preserveRuntimeAuthoredWorkspaceSessionFields(session, prior))
@@ -2,8 +2,12 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover'
import {
WSL_MOVE_CUTOVER_PHASES,
wslMoveJournalKey
} from '../../../shared/wsl-move/wsl-move-cutover'
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
import { orcadMigrationCutoverJournalDirectory } from '../../ssh/orcad-migration-cutover-journal'
import {
listWslMoveCutoverChain,
@@ -47,7 +51,7 @@ describe('WSL move journal', () => {
})
it('refuses a second move into a distro while the first is in flight', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))
expect(() =>
writeWslMoveCutover(
userDataPath,
@@ -57,7 +61,7 @@ describe('WSL move journal', () => {
})
it('chains a delta move after a finished one, and only onto the head', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
expect(() => writeWslMoveCutover(userDataPath, cutover('move-2'))).toThrow(
'wsl_move_must_extend_its_chain'
)
@@ -77,7 +81,7 @@ describe('WSL move journal', () => {
})
it('never moves a record backwards, rebinds it, or advances a superseded one', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))).toThrow(
'wsl_move_phase_regressed'
)
@@ -87,6 +91,7 @@ describe('WSL move journal', () => {
cutover('move-1', 'retained', {}, { repoIds: ['repo-1', 'repo-2'] })
)
).toThrow('wsl_move_binding_changed')
writeWslMoveCutover(userDataPath, cutover('move-1', 'client-rewritten'))
writeWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
writeWslMoveCutover(
userDataPath,
@@ -97,7 +102,7 @@ describe('WSL move journal', () => {
})
it('hands out records a caller cannot edit into the next comparison', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
const [listed] = listWslMoveCutovers(userDataPath)
if (listed) {
listed.phase = 'prepared'
@@ -122,12 +127,40 @@ describe('WSL move journal', () => {
).toThrow()
})
it('keeps the fence: an in-flight record cannot be removed', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
it('refuses a later phase whose record is missing or was lifted, or that skips a phase', () => {
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))).toThrow(
'wsl_move_record_missing'
)
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
removeWslMoveCutover(userDataPath, 'move-1')
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))).toThrow(
'wsl_move_record_missing'
)
expect(listWslMoveCutovers(userDataPath)).toEqual([])
writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
expect(() => writeWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))).toThrow(
'wsl_move_phase_skipped'
)
})
it.each(WSL_MOVE_CUTOVER_PHASES.slice(2, -1))('refuses to remove a %s record', (phase) => {
seedWslMoveCutover(userDataPath, cutover('move-1', phase))
expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight')
expect(listWslMoveCutovers(userDataPath)[0]?.phase).toBe(phase)
})
it('keeps the fence: a record past quiesce cannot be removed', () => {
seedWslMoveCutover(userDataPath, cutover('move-1', 'quiesced'))
expect(() => removeWslMoveCutover(userDataPath, 'move-1')).toThrow('wsl_move_in_flight')
expect(listWslMoveCutovers(userDataPath)).toHaveLength(1)
})
it('lifts a fence whose quiesce refused: nothing reached the destination yet', () => {
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
removeWslMoveCutover(userDataPath, 'move-1')
expect(listWslMoveCutovers(userDataPath)).toEqual([])
})
it('fails closed on a manifest edited without re-signing it', () => {
const record = cutover()
const edited = {
@@ -81,7 +81,8 @@ export function listWslMoveCutoverChain(
}
/**
* Durable before it returns. A new move into a distro must extend the head of its chain, and only
* Durable before it returns. A record starts at `prepared` and then advances one phase at a time
* from the one on disk. A new move into a distro must extend the head of its chain, and only
* once that head finished: one in-flight move per distro (design, section 4.3).
*/
export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutover): void {
@@ -97,6 +98,10 @@ export function writeWslMoveCutover(userDataPath: string, cutover: WslMoveCutove
const chain = listWslMoveCutoverChain(userDataPath, parsed.journalKey)
const existing = chain.find((entry) => entry.migrationId === parsed.migrationId)
const head = chain.at(-1)
// Compare-and-swap: a writer whose record was lifted underneath it must not recreate the fence.
if (!existing && parsed.phase !== 'prepared') {
throw new Error('wsl_move_record_missing')
}
if (existing) {
assertWslMoveUpdate(existing, parsed, head)
} else if (!head) {
@@ -130,20 +135,34 @@ function assertWslMoveUpdate(
if (order(next.phase) < order(existing.phase)) {
throw new Error('wsl_move_phase_regressed')
}
if (order(next.phase) > order(existing.phase) + 1) {
throw new Error('wsl_move_phase_skipped')
}
if (head && head.migrationId !== existing.migrationId && next.phase !== existing.phase) {
throw new Error('wsl_move_superseded')
}
}
// Before quiesce nothing has reached the destination, so lifting the fence loses nothing.
const REMOVABLE_IN_FLIGHT_PHASES: ReadonlySet<WslMoveCutover['phase']> = new Set([
'prepared',
'source-fenced'
])
/**
* Only a record that fences nothing goes: one never fenced (`prepared`) or one finished. A move in
* between keeps its fence until it resumes, or until a cancel proves the destination holds nothing.
* Only a record whose move has touched nothing beyond the source goes, or one finished. A move
* further along keeps its fence until it resumes, or until a cancel proves the destination holds
* nothing.
*/
export function removeWslMoveCutover(userDataPath: string, migrationId: string): void {
const record = listWslMoveCutovers(userDataPath).find(
(entry) => entry.migrationId === migrationId
)
if (record && record.phase !== 'prepared' && !isWslMoveCutoverFinished(record)) {
if (
record &&
!REMOVABLE_IN_FLIGHT_PHASES.has(record.phase) &&
!isWslMoveCutoverFinished(record)
) {
throw new Error('wsl_move_in_flight')
}
journals.remove(userDataPath, migrationId)
@@ -4,16 +4,30 @@ import { wslMoveFenceFromCutovers } from './wsl-move-fence'
const ids = (state: ReturnType<typeof wslMoveFenceFromCutovers>) => ({
fenced: [...state.fenced.repoIds, ...state.fenced.folderWorkspaceIds],
hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds]
hidden: [...state.hidden.repoIds, ...state.hidden.folderWorkspaceIds],
sessionFrozen: [...state.sessionFrozen.repoIds, ...state.sessionFrozen.folderWorkspaceIds]
})
describe('WSL move fence', () => {
it.each([
['prepared', { fenced: [], hidden: [] }],
['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [] }],
['blobs-transferred', { fenced: ['repo-1', 'notes'], hidden: [] }],
['destination-committed', { fenced: [], hidden: ['repo-1', 'notes'] }],
['retained', { fenced: [], hidden: ['repo-1', 'notes'] }]
['prepared', { fenced: [], hidden: [], sessionFrozen: [] }],
// The quiesce's own Sleep still writes the session here.
['source-fenced', { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: [] }],
['quiesced', { fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: ['repo-1', 'notes'] }],
[
'blobs-transferred',
{ fenced: ['repo-1', 'notes'], hidden: [], sessionFrozen: ['repo-1', 'notes'] }
],
// Through commit: the retained local session is what a downgrade restores.
[
'destination-committed',
{ fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }
],
[
'client-rewritten',
{ fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }
],
['retained', { fenced: [], hidden: ['repo-1', 'notes'], sessionFrozen: ['repo-1', 'notes'] }]
] as const)('%s', (phase, expected) => {
const record = cutover(
'move-1',
@@ -29,6 +43,10 @@ describe('WSL move fence', () => {
cutover('move-1', 'retained', {}, { repoIds: ['repo-1'] }),
cutover('move-2', 'quiesced', { supersedesMigrationId: 'move-1' }, { repoIds: ['repo-2'] })
])
expect(ids(state)).toEqual({ fenced: ['repo-2'], hidden: ['repo-1'] })
expect(ids(state)).toEqual({
fenced: ['repo-2'],
hidden: ['repo-1'],
sessionFrozen: ['repo-1', 'repo-2']
})
})
})
@@ -3,9 +3,10 @@
* lives outside the profile store, so an older build rewriting the profile cannot drop it, and the
* scope it names is the manifest's own (design, section 4.3).
*
* - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits, new
* terminals and session writes for these rows are refused.
* - Fenced (read-only): from `source-fenced` until the host owns the projects. Edits and new
* terminals are refused.
* - Hidden: once the destination committed. The source rows stay (retained) but are not listed.
* - Session frozen: from `quiesced` on, hidden or not, the session stays as the quiesce left it.
*/
import type { WslMoveCutover, WslMoveCutoverPhase } from '../../../shared/wsl-move/wsl-move-cutover'
import { WSL_MOVE_CUTOVER_PHASES } from '../../../shared/wsl-move/wsl-move-cutover'
@@ -21,11 +22,15 @@ export type WslMoveFenceState = {
fenced: WslMoveScopeIds
/** Rows the WSL host now owns: kept for a downgrade, never listed. */
hidden: WslMoveScopeIds
/** Rows already paused, through commit: their session stays as the quiesce left it. */
sessionFrozen: WslMoveScopeIds
}
const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase)
const FENCED_FROM = phaseIndex('source-fenced')
const HIDDEN_FROM = phaseIndex('destination-committed')
// Why not from `source-fenced`: the quiesce's own Sleep writes the slept panes' session after that.
const SESSION_FROZEN_FROM = phaseIndex('quiesced')
/** Throws when the journal cannot be read: callers keep every WSL project fenced then. */
export function resolveWslMoveFence(userDataPath: string): WslMoveFenceState {
@@ -37,19 +42,31 @@ export function wslMoveFenceFromCutovers(
): WslMoveFenceState {
const fenced = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
const hidden = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
const sessionFrozen = { repoIds: new Set<string>(), folderWorkspaceIds: new Set<string>() }
for (const cutover of cutovers) {
const index = phaseIndex(cutover.phase)
const target = index >= HIDDEN_FROM ? hidden : index >= FENCED_FROM ? fenced : null
if (!target) {
continue // `prepared` touches nothing on the source.
}
const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source
for (const id of scopeRepoIds) {
target.repoIds.add(id)
}
for (const id of scopeFolderWorkspaceIds) {
target.folderWorkspaceIds.add(id)
addScope(target, cutover)
// Through commit too: the retained local rows are what a downgrade restores.
if (index >= SESSION_FROZEN_FROM) {
addScope(sessionFrozen, cutover)
}
}
return { fenced, hidden }
return { fenced, hidden, sessionFrozen }
}
function addScope(
target: { repoIds: Set<string>; folderWorkspaceIds: Set<string> },
cutover: Readonly<WslMoveCutover>
): void {
const { scopeRepoIds, scopeFolderWorkspaceIds } = cutover.manifest.source
for (const id of scopeRepoIds) {
target.repoIds.add(id)
}
for (const id of scopeFolderWorkspaceIds) {
target.folderWorkspaceIds.add(id)
}
}
@@ -0,0 +1,246 @@
import { describe, expect, it, vi } from 'vitest'
import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover'
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import type { WslMoveExitProof } from './wsl-move-exit-proof'
import {
quiesceWslMove,
type WslMoveQuiesceDeps,
type WslMoveQuiesceScope
} from './wsl-move-quiesce'
import type { WslMovePaneFacts } from './wsl-move-terminal-gate'
const WORKTREE = 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-1'
const scope: WslMoveQuiesceScope = {
workspaceIds: [WORKTREE],
workspaceKeys: new Set([WORKTREE]),
panes: [{ paneKey: 'tab-1:leaf-1', sessionId: 'session-1' }]
}
const idlePane: WslMovePaneFacts = {
paneKey: 'tab-1:leaf-1',
foreground: 'shell-idle',
scrollbackBytes: 10
}
const hints = {
hints: [{ paneKey: 'tab-1:leaf-1', cwd: '/home/dev/repo-1' }],
unreadablePaneKeys: [],
staleCwdPaneKeys: []
}
function harness(overrides: Partial<WslMoveQuiesceDeps> = {}) {
const journal: WslMoveCutover['phase'][] = []
const removed: string[] = []
const steps: string[] = []
const deps: WslMoveQuiesceDeps = {
gateInputs: async () => ({
panes: [idlePane],
runningAutomationRunIds: [],
activeDispatchIds: []
}),
sleepWorkspaces: vi.fn(async () => {
steps.push(`sleep after ${journal.at(-1)}`)
return { failedWorktreeIds: [] }
}),
livePtyIds: () => [],
captureRestoreHints: async () => hints,
proveExited: async (): Promise<WslMoveExitProof> => ({ verdict: 'exited' }),
writeCutover: (record) => {
journal.push(record.phase)
},
removeCutover: (migrationId) => {
removed.push(migrationId)
},
now: () => new Date('2026-10-14T00:00:00.000Z'),
...overrides
}
return { deps, journal, removed, steps }
}
describe('quiesceWslMove', () => {
it('fences, sleeps, proves exit and records the proven sessions', async () => {
const { deps, journal, removed, steps } = harness()
const result = await quiesceWslMove(cutover(), scope, deps)
expect(journal).toEqual(['source-fenced', 'quiesced'])
expect(steps).toEqual(['sleep after source-fenced'])
expect(removed).toEqual([])
expect(result).toMatchObject({
outcome: 'quiesced',
cutover: { phase: 'quiesced', provenExitedSessionIds: ['session-1'] },
restoreHints: hints
})
})
it('refuses with the blockers before fencing anything', async () => {
const { deps, journal } = harness({
gateInputs: async () => ({
panes: [{ ...idlePane, foreground: 'process-running' }],
runningAutomationRunIds: [],
activeDispatchIds: []
})
})
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({
outcome: 'refused',
reason: 'terminals-blocked',
blockers: [{ kind: 'process-running', ids: ['tab-1:leaf-1'] }],
fenceLifted: true
})
expect(journal).toEqual([])
expect(deps.sleepWorkspaces).not.toHaveBeenCalled()
})
it('gates again under the fence and refuses a pane that turned busy, without sleeping', async () => {
const phases: (WslMoveCutover['phase'] | undefined)[] = []
let calls = 0
const { deps, journal, removed } = harness()
deps.gateInputs = async () => {
phases.push(journal.at(-1))
calls += 1
const foreground = calls === 1 ? 'shell-idle' : 'process-running'
return {
panes: [{ ...idlePane, foreground }],
runningAutomationRunIds: [],
activeDispatchIds: []
}
}
const result = await quiesceWslMove(cutover(), scope, deps)
expect(phases).toEqual([undefined, 'source-fenced'])
expect(result).toMatchObject({
outcome: 'refused',
reason: 'terminals-blocked',
blockers: [{ kind: 'process-running', ids: ['tab-1:leaf-1'] }],
fenceLifted: true
})
expect(deps.sleepWorkspaces).not.toHaveBeenCalled()
expect(removed).toEqual(['move-1'])
})
it('refuses a second quiesce of the same distro while the first runs', async () => {
let releaseSleep: () => void = () => undefined
const first = harness({
sleepWorkspaces: () =>
new Promise((resolve) => {
releaseSleep = () => resolve({ failedWorktreeIds: [] })
})
})
const running = quiesceWslMove(cutover(), scope, first.deps)
await vi.waitFor(() => expect(first.journal).toEqual(['source-fenced']))
const second = harness()
await expect(quiesceWslMove(cutover('move-2'), scope, second.deps)).resolves.toEqual({
outcome: 'refused',
reason: 'quiesce-in-progress',
fenceLifted: true
})
expect(second.journal).toEqual([])
releaseSleep()
await expect(running).resolves.toMatchObject({ outcome: 'quiesced' })
await expect(quiesceWslMove(cutover('move-2'), scope, second.deps)).resolves.toMatchObject({
outcome: 'quiesced'
})
})
it.each([
['renderer_unavailable', 'renderer-unavailable'],
['worktree_sleep_timeout', 'sleep-timed-out'],
['persist failed', 'sleep-failed']
])('lifts the fence when Sleep rejects with %s', async (message, reason) => {
const { deps, journal, removed } = harness({
sleepWorkspaces: async () => {
throw new Error(message)
}
})
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({ outcome: 'refused', reason, fenceLifted: true })
expect(journal).toEqual(['source-fenced'])
expect(removed).toEqual(['move-1'])
})
it('lifts the fence and names the workspaces whose Sleep failed', async () => {
const { deps, removed } = harness({
sleepWorkspaces: async () => ({ failedWorktreeIds: [WORKTREE] })
})
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({
outcome: 'refused',
reason: 'sleep-failed',
failedWorktreeIds: [WORKTREE]
})
expect(removed).toEqual(['move-1'])
})
it('refuses a terminal opened in scope after Sleep, without touching the daemon proof', async () => {
const proveExited = vi.fn(async (): Promise<WslMoveExitProof> => ({ verdict: 'exited' }))
const { deps, journal, removed } = harness({ livePtyIds: () => ['pty-new'], proveExited })
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({ reason: 'terminals-live', liveSessionIds: ['pty-new'] })
expect(proveExited).not.toHaveBeenCalled()
expect(journal).toEqual(['source-fenced'])
expect(removed).toEqual(['move-1'])
})
it.each<[WslMoveExitProof, Record<string, unknown>]>([
[
{ verdict: 'live', sessionIds: ['session-1'] },
{ reason: 'terminals-live', liveSessionIds: ['session-1'] }
],
[
{ verdict: 'unverifiable', reason: 'a daemon generation did not answer' },
{ reason: 'exit-unverifiable', detail: 'a daemon generation did not answer' }
]
])('refuses unless the daemon proves exit: %o', async (proof, expected) => {
const { deps, journal, removed } = harness({ proveExited: async () => proof })
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({ outcome: 'refused', ...expected, fenceLifted: true })
expect(journal).toEqual(['source-fenced'])
expect(removed).toEqual(['move-1'])
})
it('lifts the fence when reading the restore hints throws', async () => {
const { deps, removed } = harness({
captureRestoreHints: async () => {
throw new Error('disk')
}
})
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({ reason: 'quiesce-failed', detail: 'disk' })
expect(removed).toEqual(['move-1'])
})
it('reports a fence it could not lift', async () => {
const { deps } = harness({
proveExited: async () => ({ verdict: 'live', sessionIds: ['session-1'] }),
removeCutover: () => {
throw new Error('wsl_move_in_flight')
}
})
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({ outcome: 'refused', fenceLifted: false })
})
it('refuses without lifting anything when the fence cannot be written', async () => {
const { deps, removed } = harness({
writeCutover: () => {
throw new Error('wsl_move_already_in_flight')
}
})
const result = await quiesceWslMove(cutover(), scope, deps)
expect(result).toMatchObject({ reason: 'fence-failed', fenceLifted: true })
expect(removed).toEqual([])
expect(deps.sleepWorkspaces).not.toHaveBeenCalled()
})
it('only starts from a prepared move', async () => {
const { deps } = harness()
await expect(quiesceWslMove(cutover('move-1', 'source-fenced'), scope, deps)).rejects.toThrow(
'wsl_move_not_prepared'
)
})
})
@@ -0,0 +1,212 @@
/**
* Pausing a moving project's terminals (mover design, section 3.3): the gate must pass, the fence
* goes up, Sleep pauses every workspace in scope, and the daemon must prove each session exited
* before the journal says `quiesced`. Nothing is ever ended to make progress.
*
* A refusal after the fence lifts it again. Workspaces Sleep already paused stay asleep: that is
* 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 {
assessWslMoveTerminals,
type WslMoveGateBlocker,
type WslMoveGateInputs
} from './wsl-move-terminal-gate'
export type WslMoveQuiesceScope = {
/** Every worktree and folder workspace of the moving projects; each one is slept. */
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 }[]
}
export type WslMoveQuiesceDeps = {
gateInputs: () => Promise<WslMoveGateInputs>
/** Resolves with the workspaces whose Sleep failed; rejects when the renderer cannot answer. */
sleepWorkspaces: (workspaceIds: readonly string[]) => Promise<{ failedWorktreeIds: string[] }>
/** PTYs this process still holds for the workspaces, daemon-backed or not. */
livePtyIds: (workspaceIds: readonly string[]) => readonly string[]
captureRestoreHints: (
panes: WslMoveQuiesceScope['panes'],
distro: string
) => Promise<WslMoveRestoreHintCapture>
proveExited: (scope: {
workspaceKeys: ReadonlySet<string>
sessionIds: ReadonlySet<string>
}) => Promise<WslMoveExitProof>
writeCutover: (cutover: WslMoveCutover) => void
removeCutover: (migrationId: string) => void
now: () => Date
}
export type WslMoveQuiesceRefusalReason =
| 'terminals-blocked'
/** Another quiesce for the same distro is still running in this process. */
| 'quiesce-in-progress'
| 'fence-failed'
| 'renderer-unavailable'
| 'sleep-timed-out'
| 'sleep-failed'
| 'terminals-live'
| 'exit-unverifiable'
| 'journal-failed'
/** Anything else that threw after the fence went up. */
| 'quiesce-failed'
export type WslMoveQuiesceResult =
| {
outcome: 'quiesced'
cutover: WslMoveCutover
// Not in the journal: the schema has no field for them yet, so the caller carries them on.
restoreHints: WslMoveRestoreHintCapture
}
| {
outcome: 'refused'
reason: WslMoveQuiesceRefusalReason
blockers?: WslMoveGateBlocker[]
failedWorktreeIds?: string[]
liveSessionIds?: string[]
detail?: string
/** False only when the journal record could not be removed, so the fence still stands. */
fenceLifted: boolean
}
type Refusal = Extract<WslMoveQuiesceResult, { outcome: 'refused' }>
// Journal keys with a quiesce running in this process; the journal alone cannot order two of them.
const quiescingJournalKeys = new Set<string>()
export async function quiesceWslMove(
prepared: WslMoveCutover,
scope: WslMoveQuiesceScope,
deps: WslMoveQuiesceDeps
): Promise<WslMoveQuiesceResult> {
if (prepared.phase !== 'prepared') {
throw new Error('wsl_move_not_prepared')
}
if (quiescingJournalKeys.has(prepared.journalKey)) {
return { outcome: 'refused', reason: 'quiesce-in-progress', fenceLifted: true }
}
quiescingJournalKeys.add(prepared.journalKey)
try {
return await quiesceUnderLock(prepared, scope, deps)
} finally {
quiescingJournalKeys.delete(prepared.journalKey)
}
}
async function quiesceUnderLock(
prepared: WslMoveCutover,
scope: WslMoveQuiesceScope,
deps: WslMoveQuiesceDeps
): Promise<WslMoveQuiesceResult> {
const gate = assessWslMoveTerminals(await deps.gateInputs())
if (gate.verdict === 'blocked') {
return {
outcome: 'refused',
reason: 'terminals-blocked',
blockers: gate.blockers,
fenceLifted: true
}
}
const fenced: WslMoveCutover = {
...prepared,
phase: 'source-fenced',
updatedAt: deps.now().toISOString()
}
try {
deps.writeCutover(fenced)
} catch (error) {
return {
outcome: 'refused',
reason: 'fence-failed',
detail: describe(error),
fenceLifted: true
}
}
const refuse = (refusal: Omit<Refusal, 'outcome' | 'fenceLifted'>): Refusal => {
try {
deps.removeCutover(fenced.migrationId)
return { outcome: 'refused', ...refusal, fenceLifted: true }
} catch (error) {
console.warn('[wsl-move] Could not lift the fence after a refused quiesce:', error)
return { outcome: 'refused', ...refusal, fenceLifted: false }
}
}
try {
// The fence blocks new terminals, not commands typed into open shells; a sub-second window remains.
const fencedGate = assessWslMoveTerminals(await deps.gateInputs())
if (fencedGate.verdict === 'blocked') {
return refuse({ reason: 'terminals-blocked', blockers: fencedGate.blockers })
}
} catch (error) {
return refuse({ reason: 'quiesce-failed', detail: describe(error) })
}
try {
const { failedWorktreeIds } = await deps.sleepWorkspaces(scope.workspaceIds)
if (failedWorktreeIds.length > 0) {
return refuse({ reason: 'sleep-failed', failedWorktreeIds })
}
} catch (error) {
const detail = describe(error)
const reason =
detail === 'renderer_unavailable'
? 'renderer-unavailable'
: detail === 'worktree_sleep_timeout'
? 'sleep-timed-out'
: 'sleep-failed'
return refuse({ reason, detail })
}
try {
return await proveAndRecord(fenced, scope, deps, refuse)
} catch (error) {
return refuse({ reason: 'quiesce-failed', detail: describe(error) })
}
}
async function proveAndRecord(
fenced: WslMoveCutover,
scope: WslMoveQuiesceScope,
deps: WslMoveQuiesceDeps,
refuse: (refusal: Omit<Refusal, 'outcome' | 'fenceLifted'>) => Refusal
): Promise<WslMoveQuiesceResult> {
// Not the gate's pane probe: a slept pane has no process to ask, so it would read unverifiable.
const live = deps.livePtyIds(scope.workspaceIds)
if (live.length > 0) {
return refuse({ reason: 'terminals-live', liveSessionIds: [...live] })
}
const distro = fenced.manifest.source.distro
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 })
if (proof.verdict === 'live') {
return refuse({ reason: 'terminals-live', liveSessionIds: proof.sessionIds })
}
if (proof.verdict === 'unverifiable') {
return refuse({ reason: 'exit-unverifiable', detail: proof.reason })
}
const quiesced: WslMoveCutover = {
...fenced,
phase: 'quiesced',
updatedAt: deps.now().toISOString(),
provenExitedSessionIds: [...sessionIds]
}
try {
deps.writeCutover(quiesced)
} catch (error) {
return refuse({ reason: 'journal-failed', detail: describe(error) })
}
return { outcome: 'quiesced', cutover: quiesced, restoreHints }
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -3,11 +3,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import {
removeWslMoveCutover,
writeWslMoveCutover,
wslMoveJournalDirectory
} from './wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
import { removeWslMoveCutover, wslMoveJournalDirectory } from './wsl-move-cutover-journal'
import {
WslMoveProjectFencedError,
assertWslMoveProjectWritable,
@@ -67,31 +64,31 @@ describe('wslMoveProjectRefForWorkspace', () => {
describe('WSL move scope guard', () => {
it('fences nothing before startup loads the journal', () => {
resetWslMoveFenceForTests()
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
expect(isWslMoveFenceLoaded()).toBe(false)
expect(writable('repo-1::/x')).not.toThrow()
})
it('follows a write under another spelling of the same profile folder', () => {
loadWslMoveFence(`${userDataPath}/./`)
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
})
it('lifts the fence when the finished move is removed', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'retained'))
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
removeWslMoveCutover(userDataPath, 'move-1')
expect(writable('repo-1::/x')).not.toThrow()
})
it('never matches a project on another host', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
expect(writable('ssh:target-1|repo-1::/x')).not.toThrow()
})
it('follows journal writes without rereading on every check', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
rmSync(wslMoveJournalDirectory(userDataPath), { recursive: true, force: true })
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
@@ -103,12 +100,12 @@ describe('WSL move scope guard', () => {
})
it('leaves a prepared move untouched', () => {
writeWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
seedWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
expect(writable('repo-1::/x')).not.toThrow()
})
it('refuses work in a fenced project and keeps it listed', () => {
writeWslMoveCutover(
seedWslMoveCutover(
userDataPath,
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] })
)
@@ -120,7 +117,7 @@ describe('WSL move scope guard', () => {
})
it('hides and refuses a project the WSL host owns', () => {
writeWslMoveCutover(
seedWslMoveCutover(
userDataPath,
cutover('move-1', 'destination-committed', {}, { folderWorkspaceIds: ['notes'] })
)
@@ -131,7 +128,7 @@ describe('WSL move scope guard', () => {
})
it("keeps the host's own row, which carries the moved project's id", () => {
writeWslMoveCutover(
seedWslMoveCutover(
userDataPath,
cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] })
)
@@ -155,7 +152,7 @@ describe('WSL move scope guard', () => {
describe('WSL move edit guard', () => {
const fenceRepo1AndNotes = () =>
writeWslMoveCutover(
seedWslMoveCutover(
userDataPath,
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] })
)
@@ -200,7 +197,7 @@ describe('WSL move edit guard', () => {
})
it('freezes catalog settings only while a source repo is mid-move', () => {
writeWslMoveCutover(
seedWslMoveCutover(
userDataPath,
cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] })
)
@@ -70,7 +70,10 @@ function readFence(userDataPath: string): FenceView {
try {
return { readable: true, state: resolveWslMoveFence(userDataPath) }
} catch (error) {
console.warn('[wsl-move] Journal unreadable; keeping WSL projects read-only:', error)
console.warn(
'[wsl-move] Journal unreadable; keeping WSL projects read-only, sessions unfrozen:',
error
)
return { readable: false }
}
}
@@ -251,6 +254,30 @@ export function isWslMoveWorkspaceBlocked(
return !!workspaceId && isWslMoveProjectBlocked(store, wslMoveProjectRefForWorkspace(workspaceId))
}
/** True once a move has paused the project: its session must stay as the quiesce left it. */
export function isWslMoveProjectSessionFrozen(ref: WslMoveProjectRef | null): boolean {
const fence = loaded?.view
return !!ref && !!fence?.readable && inScope(fence.state.sessionFrozen, ref)
}
export function isWslMoveWorkspaceSessionFrozen(workspaceId: string): boolean {
return isWslMoveProjectSessionFrozen(wslMoveProjectRefForWorkspace(workspaceId))
}
/**
* Whether any move has a frozen session; lets the hot write path skip the per-workspace scan.
* An unreadable journal freezes nothing: the edit fence already refuses visibly, and no move can
* proceed without a readable journal, so freezing would only discard sessions silently.
*/
export function hasWslMoveFrozenSessions(): boolean {
const fence = loaded?.view
if (!fence?.readable) {
return false
}
const { sessionFrozen } = fence.state
return sessionFrozen.repoIds.size > 0 || sessionFrozen.folderWorkspaceIds.size > 0
}
export function assertWslMoveWorkspaceWritable(
store: ProjectStore | undefined,
workspaceId: string | null | undefined
@@ -0,0 +1,167 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { Store } from '../../persistence'
import {
closeTestStores,
createStore,
makeTerminalTab,
testState
} from '../../persistence-test-harness'
import { wslMoveCutoverFixture as cutover } from '../__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { seedWslMoveCutover } from '../__fixtures__/wsl-mover/wsl-move-journal-seeding'
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
import { pruneWorktreeStateForRepo } from '../tracking-repos/repo-worktree-pruning'
import { wslMoveJournalDirectory } from './wsl-move-cutover-journal'
import { loadWslMoveFence, resetWslMoveFenceForTests } from './wsl-move-scope-guard'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
const MOVING = 'repo-1::/home/dev/repo-1'
const STAYING = 'repo-2::/home/dev/repo-2'
function session(title: string, tabType: 'terminal' | 'editor'): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[MOVING]: [makeTerminalTab({ id: 'moving-tab', ptyId: null, worktreeId: MOVING, title })],
[STAYING]: [makeTerminalTab({ id: 'staying-tab', ptyId: null, worktreeId: STAYING, title })]
},
terminalLayoutsByTabId: {
'moving-tab': { root: null, activeLeafId: null, expandedLeafId: null },
'staying-tab': { root: null, activeLeafId: null, expandedLeafId: null }
},
activeTabTypeByWorktree: { [MOVING]: tabType, [STAYING]: tabType }
}
}
const titles = (store: Store) => {
const { tabsByWorktree } = store.getWorkspaceSession()
return {
moving: tabsByWorktree[MOVING]?.map((tab) => tab.title),
staying: tabsByWorktree[STAYING]?.map((tab) => tab.title)
}
}
let store: Store
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-freeze-'))
store = createStore()
loadWslMoveFence(testState.dir)
store.setWorkspaceSession(session('slept', 'terminal'))
})
afterEach(async () => {
vi.restoreAllMocks()
resetWslMoveFenceForTests()
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
describe('WSL move session freeze', () => {
it("lets the quiesce's own Sleep write while the move is only fenced", () => {
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
store.setWorkspaceSession(session('after-sleep', 'terminal'))
expect(titles(store)).toEqual({ moving: ['after-sleep'], staying: ['after-sleep'] })
})
describe('once quiesced', () => {
beforeEach(() => {
seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced'))
})
it('keeps the moving workspace as the quiesce left it and takes the rest', () => {
store.setWorkspaceSession(session('edited', 'editor'))
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] })
expect(store.getWorkspaceSession().activeTabTypeByWorktree).toEqual({
[MOVING]: 'terminal',
[STAYING]: 'editor'
})
})
it('keeps it when a write omits the workspace entirely', () => {
const without = session('edited', 'editor')
delete without.tabsByWorktree[MOVING]
store.setWorkspaceSession(without)
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] })
})
it('keeps it through a patch and the before-unload stage', () => {
store.patchWorkspaceSession({
activeTabTypeByWorktree: { [MOVING]: 'editor', [STAYING]: 'editor' }
})
expect(store.getWorkspaceSession().activeTabTypeByWorktree).toEqual({
[MOVING]: 'terminal',
[STAYING]: 'editor'
})
store.stageWorkspaceSessionBeforeUnload(session('quit', 'editor'))
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['quit'] })
})
})
it('keeps freezing through commit, so the retained rows survive for a downgrade', () => {
seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
store.setWorkspaceSession(session('edited', 'editor'))
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['edited'] })
})
it('freezes nothing, with one warning, when the journal cannot be read', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const directory = wslMoveJournalDirectory(testState.dir)
mkdirSync(directory, { recursive: true })
writeFileSync(join(directory, 'move-1.json'), '{not json')
loadWslMoveFence(testState.dir)
store.setWorkspaceSession(session('edited', 'editor'))
expect(titles(store)).toEqual({ moving: ['edited'], staying: ['edited'] })
expect(warn).toHaveBeenCalledTimes(1)
})
})
describe('WSL move session freeze: other session writers', () => {
beforeEach(() => {
seedWslMoveCutover(testState.dir, cutover('move-1', 'quiesced'))
})
it('keeps the frozen rows through a failed durable mutation that rolls the session back', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const refuse = () => {
throw new Error('disk refused')
}
const authority = ProfileStateSqliteAuthority.prototype
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(refuse)
vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(refuse)
vi.spyOn(authority, 'writeSerializedState').mockImplementationOnce(refuse)
await expect(
store.runDurableMutation(() => {
store.setWorkspaceSession(session('mutated', 'editor'))
return {
value: undefined,
rollback: () => store.setWorkspaceSession(session('rolled-back', 'editor'))
}
})
).rejects.toThrow('disk refused')
expect(titles(store)).toEqual({ moving: ['slept'], staying: ['rolled-back'] })
})
it('keeps the frozen rows when the repo is pruned', () => {
const state = getDefaultPersistedState(testState.dir)
state.workspaceSession = {
...store.getWorkspaceSession(),
lastVisitedAtByWorktreeId: { [MOVING]: 1, [STAYING]: 1 }
}
pruneWorktreeStateForRepo(state, 'repo-1', null, () => undefined)
pruneWorktreeStateForRepo(state, 'repo-2', null, () => undefined)
expect(Object.keys(state.workspaceSession.tabsByWorktree)).toEqual([MOVING])
})
it('keeps the frozen rows when the workspace owner is removed', () => {
store.removeWorkspaceSessionStateForWorktree(MOVING)
store.removeWorkspaceSessionStateForWorktree(STAYING)
expect(titles(store)).toEqual({ moving: ['slept'], staying: undefined })
})
})
@@ -0,0 +1,69 @@
/**
* Once a move has paused a project, its workspaces' session stays exactly as the quiesce left it:
* that slept state is what the move carries to the host, so a later renderer write must neither
* change nor drop it. Every other workspace's rows, and the global fields, are taken as written.
*/
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
import {
mergeWorkspaceSessionArrayField,
mergeWorkspaceSessionRecordField
} from '../../../shared/workspace-session-host-records'
import {
filterWorkspaceRows,
withoutWorkspaces,
workspaceIdsNamedByPartition
} from '../../../shared/workspace-session-stranded-partition-adoption'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { hasWslMoveFrozenSessions, isWslMoveWorkspaceSessionFrozen } from './wsl-move-scope-guard'
export function keepWslMoveFrozenSessions(
next: WorkspaceSessionState,
prior: WorkspaceSessionState | undefined,
isFrozen: (workspaceId: string) => boolean
): WorkspaceSessionState {
const frozen = new Set<string>()
for (const workspaceId of workspaceIdsNamedByPartition(next)) {
if (isFrozen(workspaceId)) {
frozen.add(workspaceId)
}
}
for (const workspaceId of prior ? workspaceIdsNamedByPartition(prior) : []) {
if (isFrozen(workspaceId)) {
frozen.add(workspaceId)
}
}
if (frozen.size === 0) {
return next
}
const merged = withoutWorkspaces(next, frozen)
if (!prior) {
return merged
}
const kept = filterWorkspaceRows(prior, (workspaceId) => frozen.has(workspaceId))
for (const [field, ownership] of Object.entries(WORKSPACE_SESSION_FIELD_OWNERSHIP)) {
if (!isSessionField(field) || ownership === 'global' || ownership === 'hostPrivate') {
continue
}
if (ownership === 'worktreeArray') {
mergeWorkspaceSessionArrayField(merged, field, kept)
} else {
mergeWorkspaceSessionRecordField(merged, field, kept)
}
}
return merged
}
function isSessionField(field: string): field is keyof WorkspaceSessionState {
return Object.hasOwn(WORKSPACE_SESSION_FIELD_OWNERSHIP, field)
}
/** The store's write paths: a no-op unless some move has paused a project. */
export function keepWslMoveFrozenSessionsForStore(
next: WorkspaceSessionState,
prior: WorkspaceSessionState | undefined
): WorkspaceSessionState {
if (!hasWslMoveFrozenSessions()) {
return next
}
return keepWslMoveFrozenSessions(next, prior, isWslMoveWorkspaceSessionFrozen)
}
@@ -6,8 +6,15 @@ import {
type ExecutionHostId
} from '../../../shared/execution-host'
import { workspaceSessionPartitionHostId } from '../../../shared/workspace-session-partition-owner'
import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
import { cloneWorkspaceSessionState, deleteOwnerKeyedSessionFields } from './session-owner-fields'
/** Owners whose rows may go: a paused WSL move keeps its rows for a downgrade. */
export function withoutWslMoveFrozenOwners(ownerKeys: ReadonlySet<string>): ReadonlySet<string> {
const kept = [...ownerKeys].filter((ownerKey) => !isWslMoveWorkspaceSessionFrozen(ownerKey))
return kept.length === ownerKeys.size ? ownerKeys : new Set(kept)
}
// Scans the pane-key-keyed maps, the close records and the shutdown list once, removing
// every entry owned by a key matched by `isRemovedOwner` (or, for pty incarnations, whose
// tab was removed). Kept separate from the O(1) deletes so a batch prune scans each
@@ -81,6 +88,9 @@ export function removeWorkspaceSessionOwnerEverywhere(
ownerKey: string,
options: { advanceTerminalTopologyRevision?: boolean } = {}
): void {
if (isWslMoveWorkspaceSessionFrozen(ownerKey)) {
return
}
state.workspaceSession = removeWorkspaceSessionOwner(state.workspaceSession, ownerKey, options)!
const partitions = state.workspaceSessionsByHostId
if (!partitions) {
@@ -134,8 +144,9 @@ export function removeWorkspaceSessionOwners(
export function removeWorkspaceSessionOwnersEverywhere(
state: Pick<PersistedState, 'workspaceSession' | 'workspaceSessionsByHostId'>,
ownerKeys: ReadonlySet<string>
allOwnerKeys: ReadonlySet<string>
): void {
const ownerKeys = withoutWslMoveFrozenOwners(allOwnerKeys)
if (ownerKeys.size === 0) {
return
}
@@ -1,4 +1,4 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type {
TerminalLeafMoveRequest,
@@ -24,6 +24,7 @@ import {
import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state'
import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move'
import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze'
// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and
// the pane move; the close transform still lives in runtime/ and other writers move here later.
@@ -147,6 +148,17 @@ export type TerminalTopologyCommitContext = {
}
/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */
// A paused WSL move keeps its local rows as the quiesce left them.
function keepFrozen(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
context: TerminalTopologyCommitContext
): WorkspaceSessionState {
return hostId === LOCAL_EXECUTION_HOST_ID
? keepWslMoveFrozenSessionsForStore(session, context.getSession(hostId))
: session
}
function writeRestorable<V>(read: () => V, write: (value: V) => void, next: V): () => void {
const prior = read()
write(next)
@@ -173,7 +185,7 @@ function commitLeafMove(
writeRestorable(
() => context.getSession(hostId),
(value) => context.markDirty(assignWorkspaceSessionPartition(state, hostId, value)),
session
keepFrozen(hostId, session, context)
)
)
const rekeyed = rekeyMovedLeafProfileRecords(state, request)
@@ -214,7 +226,7 @@ function commitPaneChange<T>(
const restore = writeRestorable(
() => context.getSession(hostId),
(value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)),
change.session
keepFrozen(hostId, change.session, context)
)
return { value: change.value, rollback: restore }
}
@@ -3,7 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
import {
removeWorkspaceSessionOwners,
withoutWslMoveFrozenOwners
} from '../restoring-sessions/session-owner-removal'
import {
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
@@ -105,7 +108,10 @@ export function pruneWorktreeStateForRepo(
const pruneLegacyLocalSession = hostId === null || hostId === LOCAL_EXECUTION_HOST_ID
const pruneAllHostPartitions = hostId === null
if (pruneLegacyLocalSession) {
state.workspaceSession = removeWorkspaceSessionOwners(state.workspaceSession, ownerKeysToPrune)!
state.workspaceSession = removeWorkspaceSessionOwners(
state.workspaceSession,
withoutWslMoveFrozenOwners(ownerKeysToPrune)
)!
}
if (state.workspaceSessionsByHostId) {
for (const [partitionHostId, session] of Object.entries(state.workspaceSessionsByHostId)) {
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
import type { Store } from '../persistence'
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import {
WslMoveProjectFencedError,
loadWslMoveFence,
@@ -71,7 +71,7 @@ beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-meta-'))
store = createStore()
loadWslMoveFence(testState.dir)
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
})
afterEach(async () => {
resetWslMoveFenceForTests()
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import {
WslMoveProjectFencedError,
loadWslMoveFence,
@@ -107,7 +107,7 @@ describe('adoptRuntimeProvisionedRoot', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-adopt-'))
try {
loadWslMoveFence(userDataPath)
writeWslMoveCutover(
seedWslMoveCutover(
userDataPath,
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-vm'] })
)
@@ -0,0 +1,112 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeSleepRequest } from '../../shared/worktree-sleep-request'
const ipcEmitter = new EventEmitter()
vi.mock('electron', () => ({
ipcMain: {
on: (channel: string, listener: (...args: unknown[]) => void) =>
ipcEmitter.on(channel, listener),
removeListener: (channel: string, listener: (...args: unknown[]) => void) =>
ipcEmitter.removeListener(channel, listener)
}
}))
const { requestWorktreeSleepFromRenderer, worktreeSleepTimeoutMs } =
await import('./worktree-sleep-request-relay')
class FakeContents extends EventEmitter {
destroyed = false
readonly sent: WorktreeSleepRequest[] = []
isDestroyed(): boolean {
return this.destroyed
}
send(_channel: string, request: WorktreeSleepRequest): void {
this.sent.push(request)
}
}
class FakeWindow extends EventEmitter {
readonly webContents = new FakeContents()
isDestroyed(): boolean {
return false
}
}
const respond = (sender: unknown, response: Record<string, unknown>): void => {
ipcEmitter.emit('ui:worktreeSleepResponse', { sender }, response)
}
let window: FakeWindow
beforeEach(() => {
ipcEmitter.removeAllListeners()
window = new FakeWindow()
})
afterEach(() => vi.useRealTimers())
describe('requestWorktreeSleepFromRenderer', () => {
it("resolves with the renderer's failed workspaces once its own window acks", async () => {
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1', 'wt-2'])
const [request] = window.webContents.sent
expect(request?.worktreeIds).toEqual(['wt-1', 'wt-2'])
respond(new FakeContents(), { requestId: request?.requestId })
respond(window.webContents, { requestId: 'other' })
respond(window.webContents, { requestId: request?.requestId, failedWorktreeIds: ['wt-2'] })
await expect(pending).resolves.toEqual({ failedWorktreeIds: ['wt-2'] })
expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0)
expect(window.listenerCount('closed')).toBe(0)
})
it('rejects with the error the renderer reports', async () => {
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1'])
respond(window.webContents, {
requestId: window.webContents.sent[0]?.requestId,
error: 'persist failed'
})
await expect(pending).rejects.toThrow('persist failed')
})
it.each([
['the window closes', (target: FakeWindow) => target.emit('closed')],
['the renderer dies', (target: FakeWindow) => target.webContents.emit('render-process-gone')],
['the renderer reloads', (target: FakeWindow) => target.webContents.emit('did-start-loading')]
])('rejects renderer_unavailable when %s', async (_name, lose) => {
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1'])
lose(window)
await expect(pending).rejects.toThrow('renderer_unavailable')
expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0)
expect(window.webContents.listenerCount('destroyed')).toBe(0)
expect(window.webContents.listenerCount('did-start-loading')).toBe(0)
})
it('rejects renderer_unavailable up front when the renderer is already gone', async () => {
window.webContents.destroyed = true
await expect(requestWorktreeSleepFromRenderer(window, ['wt-1'])).rejects.toThrow(
'renderer_unavailable'
)
expect(window.webContents.sent).toEqual([])
})
it('times out instead of waiting forever', async () => {
vi.useFakeTimers()
const pending = requestWorktreeSleepFromRenderer(window, ['wt-1'])
let done = false
const settled = expect(pending.finally(() => (done = true))).rejects.toThrow(
'worktree_sleep_timeout'
)
await vi.advanceTimersByTimeAsync(29_999)
expect(done).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await settled
expect(ipcEmitter.listenerCount('ui:worktreeSleepResponse')).toBe(0)
})
it('scales the wait with the workspaces, capped at five minutes', () => {
expect(worktreeSleepTimeoutMs(0)).toBe(20_000)
expect(worktreeSleepTimeoutMs(3)).toBe(50_000)
expect(worktreeSleepTimeoutMs(28)).toBe(300_000)
expect(worktreeSleepTimeoutMs(500)).toBe(300_000)
})
})
@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type {
WorktreeSleepRequest,
WorktreeSleepResponse
} from '../../shared/worktree-sleep-request'
const WORKTREE_SLEEP_BASE_TIMEOUT_MS = 20_000
const WORKTREE_SLEEP_PER_WORKSPACE_MS = 10_000
const WORKTREE_SLEEP_MAX_TIMEOUT_MS = 5 * 60_000
/** One request sleeps every workspace of a project in turn, so the wait grows with them. */
export function worktreeSleepTimeoutMs(workspaceCount: number): number {
return Math.min(
WORKTREE_SLEEP_BASE_TIMEOUT_MS + WORKTREE_SLEEP_PER_WORKSPACE_MS * workspaceCount,
WORKTREE_SLEEP_MAX_TIMEOUT_MS
)
}
export type WorktreeSleepResult = { failedWorktreeIds: string[] }
/** The parts of a BrowserWindow the relay touches. */
export type WorktreeSleepRendererWindow = {
isDestroyed(): boolean
readonly webContents: {
isDestroyed(): boolean
send(channel: string, request: WorktreeSleepRequest): void
once(event: 'destroyed', listener: () => void): unknown
once(event: 'render-process-gone', listener: () => void): unknown
once(event: 'did-start-loading', listener: () => void): unknown
removeListener(event: 'destroyed', listener: () => void): unknown
removeListener(event: 'render-process-gone', listener: () => void): unknown
removeListener(event: 'did-start-loading', listener: () => void): unknown
}
once(event: 'closed', listener: () => void): unknown
removeListener(event: 'closed', listener: () => void): unknown
}
// Why: no call site passes a real window yet; keep the narrow type honest against Electron's.
const browserWindowFits: BrowserWindow extends WorktreeSleepRendererWindow ? true : never = true
void browserWindowFits
/**
* Sleep runs in the renderer, which owns the slept-pane records; this waits until it has slept
* the workspaces and persisted their session. Rejects `renderer_unavailable` or
* `worktree_sleep_timeout`; there is no headless fallback.
*/
export async function requestWorktreeSleepFromRenderer(
mainWindow: WorktreeSleepRendererWindow,
worktreeIds: readonly string[]
): Promise<WorktreeSleepResult> {
if (mainWindow.isDestroyed()) {
throw new Error('renderer_unavailable')
}
const webContents = mainWindow.webContents
if (webContents.isDestroyed()) {
throw new Error('renderer_unavailable')
}
const requestId = randomUUID()
return new Promise<WorktreeSleepResult>((resolve, reject) => {
let settled = false
const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable'))
const finish = (outcome: Error | WorktreeSleepResult): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
ipcMain.removeListener('ui:worktreeSleepResponse', onResponse)
mainWindow.removeListener('closed', onRendererUnavailable)
webContents.removeListener('destroyed', onRendererUnavailable)
webContents.removeListener('render-process-gone', onRendererUnavailable)
webContents.removeListener('did-start-loading', onRendererUnavailable)
if (outcome instanceof Error) {
reject(outcome)
} else {
resolve(outcome)
}
}
const timeout = setTimeout(
() => finish(new Error('worktree_sleep_timeout')),
worktreeSleepTimeoutMs(worktreeIds.length)
)
const onResponse = (event: Electron.IpcMainEvent, response: WorktreeSleepResponse): void => {
// Why: request ids are visible to renderer code; only the selected window may answer.
if (event.sender !== webContents || response.requestId !== requestId) {
return
}
finish(
response.error
? new Error(response.error)
: { failedWorktreeIds: response.failedWorktreeIds ?? [] }
)
}
ipcMain.on('ui:worktreeSleepResponse', onResponse)
mainWindow.once('closed', onRendererUnavailable)
webContents.once('destroyed', onRendererUnavailable)
webContents.once('render-process-gone', onRendererUnavailable)
// A reload discards the renderer's pending Sleep, so it would never answer.
webContents.once('did-start-loading', onRendererUnavailable)
const request: WorktreeSleepRequest = { requestId, worktreeIds: [...worktreeIds] }
try {
webContents.send('ui:worktreeSleepRequest', request)
} catch {
finish(new Error('renderer_unavailable'))
}
})
}
@@ -73,6 +73,15 @@ export const uiClipboardAndWindowControlsApi = {
ipcRenderer.on('ui:sleepWorktree', listener)
return () => ipcRenderer.removeListener('ui:sleepWorktree', listener)
},
onWorktreeSleepRequest: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, request: Parameters<typeof callback>[0]) =>
callback(request)
ipcRenderer.on('ui:worktreeSleepRequest', listener)
return () => ipcRenderer.removeListener('ui:worktreeSleepRequest', listener)
},
respondWorktreeSleep: (response) => {
ipcRenderer.send('ui:worktreeSleepResponse', response)
},
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
callback(data)
+6
View File
@@ -48,6 +48,10 @@ import type {
SessionTabCloseRequest,
SessionTabCloseResponse
} from '../../shared/session-tab-close'
import type {
WorktreeSleepRequest,
WorktreeSleepResponse
} from '../../shared/worktree-sleep-request'
export type CloseActiveTabPayload = { sourceId: string }
@@ -262,6 +266,8 @@ export type UiCommandEventApi = {
onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void
respondTerminalTabClose: (response: TerminalTabCloseResponse) => void
onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void
onWorktreeSleepRequest: (callback: (request: WorktreeSleepRequest) => void) => () => void
respondWorktreeSleep: (response: WorktreeSleepResponse) => void
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
onSystemResumed: (callback: () => void) => () => void
@@ -48,7 +48,11 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({
withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise<unknown>) => teardown()
}))
import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow'
import {
runSleepWorktree,
runSleepWorktrees,
sleepWorktreesReportingFailures
} from './sleep-worktree-flow'
describe('runSleepWorktree', () => {
beforeEach(() => {
@@ -310,6 +314,18 @@ describe('runSleepWorktree', () => {
)
})
it('reports failed worktrees to a caller that answers for them, without a toast', async () => {
mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) =>
worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve()
)
const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2'])
expect(outcome.failedWorktreeIds).toEqual(['wt-1'])
expect(outcome.errors).toHaveLength(1)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('sleeps multiple worktrees and clears active only once when included', async () => {
mocks.state.activeWorktreeId = 'wt-2'
@@ -136,8 +136,34 @@ function describeSleepFailure(error: unknown): string {
}
export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise<void> {
const { errors } = await sleepWorktreesReportingFailures(worktreeIds)
if (errors.length > 0) {
// Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state.
toast.error(
worktreeIds.length === 1
? translate(
'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671',
'Failed to sleep workspace'
)
: translate(
'auto.components.sidebar.sleep.worktree.flow.c460fecc4a',
'Failed to sleep some workspaces'
),
{
description: errors.join('\n')
}
)
}
}
export type WorktreeSleepOutcome = { failedWorktreeIds: string[]; errors: string[] }
/** The sleep without the toast, for callers that report failures themselves. */
export async function sleepWorktreesReportingFailures(
worktreeIds: readonly string[]
): Promise<WorktreeSleepOutcome> {
if (worktreeIds.length === 0) {
return
return { failedWorktreeIds: [], errors: [] }
}
const {
activeWorktreeId,
@@ -214,21 +240,5 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise
setActiveWorktree(sleptActiveWorktreeId)
}
}
if (errors.length > 0) {
// Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state.
toast.error(
worktreeIds.length === 1
? translate(
'auto.components.sidebar.sleep.worktree.flow.8bc3fc0671',
'Failed to sleep workspace'
)
: translate(
'auto.components.sidebar.sleep.worktree.flow.c460fecc4a',
'Failed to sleep some workspaces'
),
{
description: errors.join('\n')
}
)
}
return { failedWorktreeIds: [...failedWorktreeIds], errors }
}
@@ -2,7 +2,10 @@ import { createRuntimeFilePositionQueue } from './runtime-file-position'
import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice'
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
import { detectLanguage } from '@/lib/language-detect'
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
import {
runSleepWorktree,
sleepWorktreesReportingFailures
} from '@/components/sidebar/sleep-worktree-flow'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence'
import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal'
@@ -133,6 +136,30 @@ export function registerMobileAndTerminalCloseIpcBridge(
})
)
// Why optional: an older preload lingering through an in-place reload lacks the channel.
if (window.api.ui.onWorktreeSleepRequest) {
unsubs.push(
window.api.ui.onWorktreeSleepRequest(({ requestId, worktreeIds }) => {
void (async () => {
const { failedWorktreeIds } = await sleepWorktreesReportingFailures(worktreeIds)
// Main waits on the ack, so the slept state must be durable before it.
const state = useAppStore.getState()
await persistWorkspaceSessionByHost(
window.api.session,
buildWorkspaceSessionPayload(state),
state
)
window.api.ui.respondWorktreeSleep({ requestId, failedWorktreeIds })
})().catch((error: unknown) => {
window.api.ui.respondWorktreeSleep({
requestId,
error: error instanceof Error ? error.message : 'worktree_sleep_failed'
})
})
})
)
}
unsubs.push(
window.api.ui.onResumeSleepingAgents(({ worktreeId }) => {
// Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view.
@@ -97,6 +97,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
'ui.onToggleStatusBar',
'ui.onToggleWorktreePalette',
'ui.onWorktreeHistoryNavigate',
'ui.onWorktreeSleepRequest',
'updater.onClearDismissal',
'updater.onStatus',
'workspaceSpace.onProgress',
@@ -164,6 +165,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
'ui.onCloseTerminal',
'ui.onTerminalTabCloseRequest',
'ui.onSleepWorktree',
'ui.onWorktreeSleepRequest',
'ui.onResumeSleepingAgents',
'updater.onStatus',
'updater.onClearDismissal',
@@ -277,6 +277,8 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onTerminalTabCloseRequest: () => noopUnsubscribe,
respondTerminalTabClose: () => {},
onSleepWorktree: () => noopUnsubscribe,
onWorktreeSleepRequest: () => noopUnsubscribe,
respondWorktreeSleep: () => {},
// Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web.
onResumeSleepingAgents: () => noopUnsubscribe,
onTerminalZoom: () => noopUnsubscribe,
@@ -266,6 +266,14 @@ function entryWorkspaceResolver(
export function withoutWorkspaces(
base: WorkspaceSessionState,
workspaceIds: ReadonlySet<string>
): WorkspaceSessionState {
return filterWorkspaceRows(base, (workspaceId) => !workspaceIds.has(workspaceId))
}
/** The scoped rows of the workspaces kept, by the same routing; global fields stay as they are. */
export function filterWorkspaceRows(
base: WorkspaceSessionState,
keepsWorkspace: (workspaceId: string) => boolean
): WorkspaceSessionState {
const worktreeIdByTabId = buildWorktreeIdByTabId(base)
const worktreeIdByFileId = buildWorktreeIdByFileId(base)
@@ -273,7 +281,7 @@ export function withoutWorkspaces(
for (const field of SESSION_FIELDS) {
const workspaceOf = entryWorkspaceResolver(field, worktreeIdByTabId, worktreeIdByFileId)
const keeps = (key: string, entry: unknown): boolean =>
!workspaceIds.has(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? ''))
keepsWorkspace(normalizeWorkspaceSessionKeyToWorkspaceId(workspaceOf?.(key, entry) ?? ''))
const value: unknown = base[field]
if (!workspaceOf || !value) {
continue
+12
View File
@@ -0,0 +1,12 @@
/** Main asks the renderer to Sleep workspaces and waits until their slept state is durable. */
export type WorktreeSleepRequest = {
requestId: string
worktreeIds: string[]
}
export type WorktreeSleepResponse = {
requestId: string
/** Workspaces whose Sleep failed; they stay awake and retryable. */
failedWorktreeIds?: string[]
error?: string
}
+17 -3
View File
@@ -14,6 +14,10 @@ import { waitForSessionReady } from './helpers/store'
import { createRestartSession } from './helpers/orca-restart'
import { wslMoveCutoverFixture } from '../../src/main/persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { writeWslMoveCutover } from '../../src/main/persistence/moving-wsl-projects/wsl-move-cutover-journal'
import {
WSL_MOVE_CUTOVER_PHASES,
type WslMoveCutoverPhase
} from '../../src/shared/wsl-move/wsl-move-cutover'
test.use({ seedTestRepo: false })
@@ -134,7 +138,7 @@ test('a WSL move journal fences the moving project, then hides it once the host
await session.close(launched.app)
launched = undefined
const record = (phase: 'source-fenced' | 'destination-committed') =>
const record = (phase: WslMoveCutoverPhase) =>
wslMoveCutoverFixture(
'move-1',
phase,
@@ -162,7 +166,17 @@ test('a WSL move journal fences the moving project, then hides it once the host
)
}
writeWslMoveCutover(session.userDataDir, record('source-fenced'))
// The journal only moves forward one phase at a time, as a real move writes it.
let journaled = -1
const advanceTo = (phase: WslMoveCutoverPhase) => {
const target = WSL_MOVE_CUTOVER_PHASES.indexOf(phase)
for (journaled += 1; journaled <= target; journaled += 1) {
writeWslMoveCutover(session.userDataDir, record(WSL_MOVE_CUTOVER_PHASES[journaled]))
}
journaled = target
}
advanceTo('source-fenced')
launched = await session.launch()
await waitForSessionReady(launched.page)
await expectMovingRefused(launched.page)
@@ -173,7 +187,7 @@ test('a WSL move journal fences the moving project, then hides it once the host
await session.close(launched.app)
launched = undefined
writeWslMoveCutover(session.userDataDir, record('destination-committed'))
advanceTo('destination-committed')
launched = await session.launch()
await waitForSessionReady(launched.page)
await expectMovingRefused(launched.page)