diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-delta-view.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-delta-view.ts index 08e57632a1f..9a7d7dbd78b 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-delta-view.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-delta-view.ts @@ -20,6 +20,7 @@ import { } from './orcad-source-dependency-census' import { retireOrcadMigrationSourceDormantState } from './orcad-source-dormant-retirement' import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' +import { removeOrcadMigrationScopeWorkspaceSession } from './orcad-source-workspace-session-retirement' export type OrcadMigrationDeltaView = { getRepos: () => Repo[] @@ -43,6 +44,8 @@ export function createOrcadMigrationDeltaView( const state = structuredClone(runtime.state) retireOrcadSourceCatalogState(state, moved) retireOrcadMigrationSourceDormantState(state, moved) + // The server owns the moved projects' tabs now, even ones the older build left unmovable. + removeOrcadMigrationScopeWorkspaceSession(state, moved) const storage = runtime.terminalScrollbackSnapshotStorage return { getRepos: () => state.repos, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts index b6f317f4440..7a87d02bf47 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts @@ -85,7 +85,12 @@ function collectDependencyCensus( targetId: scope.targetId }) counts['workspace-session'] = sessions.dependencyCount - counts['terminal-recovery'] = countTerminalRecoveryState(state, scope, sessions) + counts['terminal-recovery'] = countTerminalRecoveryState( + state, + scope, + sessions, + ignoreTransferredDormantState + ) const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) counts['worktree-metadata'] = metadata.rows.length + metadata.blockedCount counts['worktree-lineage'] = Object.entries(state.worktreeLineageById).filter( @@ -154,15 +159,19 @@ function collectDependencyCensus( function countTerminalRecoveryState( state: PersistedState, scope: OrcadMigrationSourceScope, - sessions: OrcadMigrationSourceSessionInspection + sessions: OrcadMigrationSourceSessionInspection, + untransferredOnly: boolean ): number { // Expired routes may still own remote work; only terminated leases resolve recovery authority. const hasActiveLease = state.sshRemotePtyLeases.some( (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' ) - let count = (state.sshPtyConsumerRecoveries ?? []).filter( - (recovery) => recovery.targetId === scope.targetId && hasActiveLease - ).length + // Never blocking: the terminal gate proves before every move that those leases exited. + let count = untransferredOnly + ? 0 + : (state.sshPtyConsumerRecoveries ?? []).filter( + (recovery) => recovery.targetId === scope.targetId && hasActiveLease + ).length count += state.migrationUnsupportedPtyEntries.filter( (entry) => orcadMigrationOwnerMatchesScope(entry.worktreeId, scope) || diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts index 98d01dea874..37c43b0e152 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -1,7 +1,6 @@ import { isWorkspaceKey } from '../../../shared/workspace-scope' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { buildMarkdownFrontmatterIdMap } from '../../orca-profiles/profile-session-owner-transfer' import { @@ -9,10 +8,7 @@ import { unqualifyOrcadMigrationOwnerKey, type OrcadMigrationSourceScope } from './orcad-source-scope' -import { - paneBelongsToTabs, - paneBelongsToTerminalLayout -} from './orcad-source-workspace-session-layout' +import { paneBelongsToTerminalLayout } from './orcad-source-workspace-session-layout' import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragments' export function countUnrepresentableMarkdownState( @@ -34,7 +30,6 @@ export function countUnrepresentableMarkdownState( } export function countUnsupportedSessionState( - state: PersistedState, session: WorkspaceSessionState, scope: OrcadMigrationSourceScope, sourceHostPartition: boolean, @@ -46,22 +41,8 @@ export function countUnsupportedSessionState( let count = sourceHostPartition ? [...collectSessionOwnerKeys(session)].filter((ownerKey) => !owns(ownerKey)).length : 0 - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!owns(ownerKey)) { - continue - } - tabs.forEach((tab) => { - count += tab.ptyId ? 1 : 0 - }) - } - for (const tabId of terminalTabIds) { - const layout = session.terminalLayoutsByTabId[tabId] - count += Object.keys(layout?.ptyIdsByLeafId ?? {}).length - count += session.remoteSessionIdsByTabId?.[tabId] ? 1 : 0 - } - count += Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).filter((paneKey) => - paneBelongsToTabs(paneKey, terminalTabIds) - ).length + // PTY bindings, remote session ids and shutdown markers are not counted: whether their terminals + // still run is the terminal gate's verdict, taken before every move; the move drops them. count += Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).filter((record) => { const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId return ( @@ -73,11 +54,6 @@ export function countUnsupportedSessionState( .filter( ([ownerKey, pages]) => !clientHostedPagesAreTransferable(session, ownerKey, pages) ).length - count += (session.activeWorktreeIdsOnShutdown ?? []).filter( - (worktreeId) => - matches(worktreeId) && - shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) - ).length count += sourceHostPartition && session.activeRepoId && @@ -87,7 +63,7 @@ export function countUnsupportedSessionState( : 0 // Focus outside the source partition is client focus, not host state: retirement retargets it. // activeConnectionIdsAtShutdown is not counted: it is the renderer's live "connected now" hint, and - // the remote work it can stand for (tab PTYs, remote session ids, leases) is counted on its own. + // the remote work it can stand for (tab PTYs, remote session ids, leases) is the terminal gate's. for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { if (owns(ownerKey)) { count += files.filter( @@ -113,52 +89,6 @@ export function countUnsupportedSessionState( return count } -export function shutdownMarkerHasTerminalAuthority( - state: PersistedState, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean, - worktreeId: string -): boolean { - const marker = unqualifyOrcadMigrationOwnerKey(worktreeId) - const ownerMatchesMarker = (ownerKey: string): boolean => - (sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope)) && - unqualifyOrcadMigrationOwnerKey(ownerKey) === marker - const tabIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!ownerMatchesMarker(ownerKey)) { - continue - } - for (const tab of tabs) { - tabIds.add(tab.id) - if (tab.ptyId) { - return true - } - const layout = session.terminalLayoutsByTabId[tab.id] - if (Object.keys(layout?.ptyIdsByLeafId ?? {}).length > 0) { - return true - } - if (session.remoteSessionIdsByTabId?.[tab.id]) { - return true - } - } - } - if ( - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).some((paneKey) => - paneBelongsToTabs(paneKey, tabIds) - ) - ) { - return true - } - return state.sshRemotePtyLeases.some( - (lease) => - lease.targetId === scope.targetId && - lease.state !== 'terminated' && - (lease.worktreeId === undefined || - unqualifyOrcadMigrationOwnerKey(lease.worktreeId) === marker) - ) -} - function clientHostedPagesAreTransferable( session: WorkspaceSessionState, ownerKey: string, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts index c541bad0cba..3d1cbb486fd 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts @@ -1,5 +1,6 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { orcadMigrationOwnerMatchesScope, type OrcadMigrationSourceScope @@ -62,3 +63,27 @@ export function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): const separator = paneKey.lastIndexOf(':') return separator > 0 && tabIds.has(paneKey.slice(0, separator)) } + +/** + * Relay PTY handles never move: the terminal gate proves their terminals exited before any move + * commits, so the destination gets the tabs and layouts without them and starts fresh shells. + */ +export function dropOrcadMigrationTerminalBindings(fragment: WorkspaceSessionState): void { + for (const tabs of Object.values(fragment.tabsByWorktree)) { + for (const tab of tabs) { + tab.ptyId = null + } + } + for (const layout of Object.values(fragment.terminalLayoutsByTabId)) { + delete layout.ptyIdsByLeafId + } + delete fragment.remoteSessionIdsByTabId + delete fragment.terminalPtyIncarnationsByPaneKey + delete fragment.activeWorktreeIdsOnShutdown + // A folder's topology fence guarded its relay PTYs only; the manifest carries repo fences alone. + for (const key of Object.keys(fragment.terminalTopologyRevisionByRepoId ?? {})) { + if (parseWorkspaceKey(key)?.type === 'folder') { + delete fragment.terminalTopologyRevisionByRepoId?.[key] + } + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts index 4debfe28e04..89154d41dee 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-retirement.ts @@ -19,6 +19,14 @@ export function retireOrcadMigrationSourceWorkspaceSession( if (!manifest.payload.dormantState?.workspaceSession) { return } + removeOrcadMigrationScopeWorkspaceSession(state, manifest) +} + +/** Every partition's session state for the manifest's catalog, whether or not it could move. */ +export function removeOrcadMigrationScopeWorkspaceSession( + state: PersistedState, + manifest: OrcadMigrationManifest +): void { const scope = createOrcadMigrationSourceScope({ source: manifest.source, catalog: manifest.payload diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index 3a4462a0c12..e9c3b5ccce4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -21,10 +21,12 @@ import { countUnsupportedSessionState, projectDormantSessionFocus, projectSessionToDestination, - shutdownMarkerHasTerminalAuthority, transferableSleepingAgentSession } from './orcad-source-workspace-session-eligibility' -import { collectOwnedTerminalTabIds } from './orcad-source-workspace-session-layout' +import { + collectOwnedTerminalTabIds, + dropOrcadMigrationTerminalBindings +} from './orcad-source-workspace-session-layout' import { mergeSessionFragments, sessionPartitions @@ -54,7 +56,6 @@ export function collectOrcadMigrationSourceWorkspaceSession( const sourceHostPartition = partitionId === scope.hostId const terminalTabIds = collectOwnedTerminalTabIds(session, scope) blockedCount += countUnsupportedSessionState( - state, session, scope, sourceHostPartition, @@ -85,18 +86,7 @@ export function collectOrcadMigrationSourceWorkspaceSession( fragment.activeWorktreeId = null delete fragment.activeWorkspaceKey } - // A shutdown marker is only a reconnect hint. Once the source has no live - // PTY authority for that worktree, carrying it would make the destination - // try to resurrect a process that no longer exists. - if (fragment.activeWorktreeIdsOnShutdown) { - fragment.activeWorktreeIdsOnShutdown = fragment.activeWorktreeIdsOnShutdown.filter( - (worktreeId) => - shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) - ) - if (fragment.activeWorktreeIdsOnShutdown.length === 0) { - delete fragment.activeWorktreeIdsOnShutdown - } - } + dropOrcadMigrationTerminalBindings(fragment) if (hasTransferredSessionState(fragment)) { const projected = projectOrcadMigrationSessionScrollback( projectSessionToDestination(fragment, scope), diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index d8d7799d6d0..1c1fb710967 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -8,6 +8,8 @@ import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-man import { listEnvironments } from '../../shared/runtime-environment-store' import type { Repo } from '../../shared/repo-types' import type { SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../../shared/workspace-scope' import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' import { Store } from '../persistence/loading-store/store' import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' @@ -135,6 +137,94 @@ function deltaMove() { }) } +const LEAF = '11111111-1111-4111-8111-111111111111' + +/** What v1.4.218 leaves after a downgrade: a terminal in what it added, and client focus there. */ +function olderBuildSessionAfterDowngrade(): void { + const hostId = `ssh:${TARGET.id}` as const + const group = store.createProjectGroup({ + name: 'downgrade-added', + parentPath: '/srv/folders', + connectionId: TARGET.id, + createdFrom: 'manual' + }) + const folder = store.createFolderWorkspace({ + projectGroupId: group.id, + name: 'downgrade-added workspace', + folderPath: '/srv/folders/added', + connectionId: TARGET.id + }) + const folderKey = folderWorkspaceKey(folder.id) + const environmentId = getManagedOrcadFenceEnvironmentId(store.getSshTarget(TARGET.id))! + const focus: Partial = { + activeRepoId: null, + activeWorktreeId: folderKey, + activeWorkspaceKey: folderKey, + activeWorkspaceExecutionHostId: hostId, + activeTabId: 'tab-term', + activeConnectionIdsAtShutdown: [TARGET.id] + } + store.setWorkspaceSession({ ...store.getWorkspaceSession(), ...focus }) + store.setWorkspaceSession( + { + ...store.getWorkspaceSession(hostId), + ...focus, + tabsByWorktree: { + [folderKey]: [ + { + id: 'tab-term', + ptyId: `${hostId}@@pty2:relay:1`, + worktreeId: folderKey, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'tab-term': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: `${hostId}@@pty2:relay:1` } + } + }, + terminalPtyIncarnationsByPaneKey: { [`tab-term:${LEAF}`]: 'incarnation-1' }, + terminalTopologyRevisionByRepoId: { [folderKey]: 1 }, + activeWorktreeIdsOnShutdown: [folderKey], + unifiedTabs: { + // The managed build stamped repo-1's editor tab with its server before the downgrade. + 'repo-1::/srv/app': [ + { + id: 'tab-editor', + entityId: '/srv/app/README.md', + groupId: 'group-editor', + worktreeId: 'repo-1::/srv/app', + executionHostId: `runtime:${environmentId}`, + contentType: 'editor', + label: 'README.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }, + hostId + ) + store.upsertSshRemotePtyLease({ + targetId: TARGET.id, + ptyId: 'pty2:relay:1', + worktreeId: folderKey, + tabId: 'tab-term', + leafId: LEAF, + state: 'expired' + }) +} + describe('moving what an older build added to a converted host', () => { it('previews additions and what the server keeps, then moves only the additions', async () => { await convertedThenChangedOnOlderBuild() @@ -224,4 +314,36 @@ describe('moving what an older build added to a converted host', () => { expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(3) expect(visibleRepos(store)).toEqual([]) }) + + it('moves what a downgrade added despite its exited terminal, tabs and client focus', async () => { + await convertedThenChangedOnOlderBuild() + olderBuildSessionAfterDowngrade() + await store.upsertSshPtyConsumerRecovery({ + targetId: TARGET.id, + clientInstanceId: 'client-1', + serverBuildId: '0.1.0', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease' + }) + reconcileManagedOrcadSshTargets(userDataPath, store, now) + + const plan = planOrcadDeltaMove(userDataPath, store, store.getSshTarget(TARGET.id)!) + expect(plan.blockers).toEqual([]) + expect(plan.added.map((row) => row.kind).sort()).toEqual([ + 'folder-workspace', + 'project-group', + 'repository' + ]) + // The relay answers with no terminals, so the expired lease is proven exited. + await expect(deltaMove()).resolves.toMatchObject({ outcome: 'moved' }) + const delta = listOrcadMigrationSourceCutovers(userDataPath).find( + (journal) => journal.supersedesMigrationId + ) + const session = delta?.manifest.payload.dormantState?.workspaceSession + expect(Object.values(session?.tabsByWorktree ?? {}).flat()).toMatchObject([ + { id: 'tab-term', ptyId: null } + ]) + expect(session?.unifiedTabs?.['repo-1::/srv/app']).toBeUndefined() + }) })