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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-04 01:59:56 -07:00
committed by GitHub
co-authored by m4air
parent 7dcc88fadc
commit b87bf57e97
7 changed files with 181 additions and 94 deletions
@@ -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,
@@ -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) ||
@@ -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<string>()
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,
@@ -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<string>):
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]
}
}
}
@@ -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
@@ -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),
@@ -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<WorkspaceSessionState> = {
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()
})
})