From b87bf57e978462ea0d9dd5bf69d0ed534e0c856f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 01:59:56 -0700 Subject: [PATCH] fix(orcad): a host re-upgraded after a downgrade can move what the older build added (#25179) The delta view left the moved projects' session state in place whenever it held anything unmovable, so it then counted against the delta. Relay PTY bindings, shutdown markers and the relay consumer's recovery record blocked every move although the terminal gate already proves those terminals exited before any move commits; the move now drops them instead. Co-authored-by: m4air --- .../orcad-source-delta-view.ts | 3 + .../orcad-source-dependency-census.ts | 19 ++- ...ad-source-workspace-session-eligibility.ts | 78 +---------- .../orcad-source-workspace-session-layout.ts | 25 ++++ ...cad-source-workspace-session-retirement.ts | 8 ++ .../orcad-source-workspace-session.ts | 20 +-- .../ssh/orcad-migration-delta-move.test.ts | 122 ++++++++++++++++++ 7 files changed, 181 insertions(+), 94 deletions(-) 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() + }) })