mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 16:02:46 +00:00
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:
@@ -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) ||
|
||||
|
||||
+4
-74
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user