Main repairs a terminal stored in two panes once at startup (dark, layout-owner build only) (#30178)

* fix(layout-owner): main repairs a terminal stored in two panes once at startup, after moving the dropped pane's owners (dark, layout-owner build only)

* fix(layout-owner): the load repair moves the dropped pane's records and skips the write when an owner move fails (dark, layout-owner build only)

* fix(layout-owner): the load repair leaves a paused WSL move's workspace untouched (dark, layout-owner build only)
This commit is contained in:
Jinwoo Hong
2026-10-17 23:02:00 -07:00
committed by GitHub
parent 483a17becc
commit b8fbfce3a6
11 changed files with 829 additions and 38 deletions
@@ -16,6 +16,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut
this.paneKeyAliasPersistenceListener = listener
}
/** The pane key that owns `paneKey`'s agent status once aliases are followed. */
resolvePaneAuthorityOwner(paneKey: string): string {
return this.resolvePaneKeyAlias(paneKey)
}
protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] {
return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) =>
entry.ptyId
@@ -0,0 +1,365 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentHookServer } from '../../agent-hooks/server'
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { repairDuplicatePtyPanesOnStart } from '../../runtime/moved-pane-owner-rekey'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture'
import { Store } from '../loading-store/store'
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
import { planDuplicatePtyPaneRepair } from './duplicate-pty-pane-repair'
import { leafIds, WindowSession } from './terminal-topology-window-session-fixture'
import {
emptyTerminalSessionProfile,
FIXTURE_GIT_WORKTREE_ID
} from './terminal-topology-profile-fixture'
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
const TAB = 'tab-1'
const KEPT = '11111111-1111-4111-8111-111111111111'
const DUPLICATE = '22222222-2222-4222-8222-222222222222'
const directories: string[] = []
const stores: Store[] = []
afterEach(async () => {
for (const store of stores.splice(0)) {
await store.freezeWritesAsync()
}
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
/** The stored shape a window save leaves behind: one terminal drawn in two panes of one tab. */
function duplicateSession(): WorkspaceSessionState {
const window = new WindowSession(getDefaultWorkspaceSession())
window.addTab(FIXTURE_GIT_WORKTREE_ID, TAB, KEPT)
window.setLayout(
TAB,
{
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: KEPT },
second: { type: 'leaf', leafId: DUPLICATE }
},
{ [KEPT]: 'pty-1', [DUPLICATE]: 'pty-1' }
)
return window.session
}
function seededDirectory(
extend: (state: Record<string, unknown>, session: WorkspaceSessionState) => void = () => {}
): string {
const directory = mkdtempSync(join(tmpdir(), 'orca-duplicate-pty-repair-'))
directories.push(directory)
const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology')
const state = {
...JSON.parse(emptyTerminalSessionProfile()),
workspaceSession: duplicateSession()
}
extend(state, state.workspaceSession)
inner.writeSerializedState(Buffer.from(JSON.stringify(state)))
inner.close()
return directory
}
const DROPPED_KEY = `${TAB}:${DUPLICATE}`
const KEPT_KEY = `${TAB}:${KEPT}`
/** Records the window files under the dropped pane: its sleep record, read marks and SSH lease. */
function withDroppedPaneRecords(state: Record<string, unknown>, session: WorkspaceSessionState) {
session.terminalPtyIncarnationsByPaneKey = { [DROPPED_KEY]: 'inc-1' }
session.sleepingAgentSessionsByPaneKey = {
[DROPPED_KEY]: {
paneKey: DROPPED_KEY,
tabId: TAB,
worktreeId: FIXTURE_GIT_WORKTREE_ID,
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'continue',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'quit'
}
}
state.ui = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture profile always carries a ui object.
...(state.ui as object),
acknowledgedAgentsByPaneKey: { [DROPPED_KEY]: 11 },
activityClearedAtByPaneKey: { [DROPPED_KEY]: 12 },
manuallyUnreadTurnsByPaneKey: { [DROPPED_KEY]: 13 }
}
state.sshRemotePtyLeases = [
{
targetId: 'target-1',
ptyId: 'pty-1',
worktreeId: FIXTURE_GIT_WORKTREE_ID,
tabId: TAB,
leafId: DUPLICATE,
state: 'detached',
createdAt: 1,
updatedAt: 1
}
]
}
function storedState(directory: string): PersistedState {
const reader = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology')
try {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test wrote this profile as PersistedState.
return JSON.parse(reader.readSerializedState() ?? '{}') as PersistedState
} finally {
reader.close()
}
}
async function load(directory: string): Promise<{ store: Store; authority: DelayedAuthority }> {
const authority = new DelayedAuthority(
new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology')
)
const store = new Store({
dataFile: join(directory, 'orca-data.json'),
profileStateAuthority: authority
})
stores.push(store)
await store.flushPendingOrThrowAsync()
return { store, authority }
}
async function quit(store: Store): Promise<void> {
await store.flushPendingOrThrowAsync()
await store.freezeWritesAsync()
stores.splice(stores.indexOf(store), 1)
}
function storedLayouts(directory: string): string {
return JSON.stringify(storedState(directory).workspaceSession?.terminalLayoutsByTabId ?? null)
}
function storedLeafIds(directory: string): string[] {
return leafIds(JSON.parse(storedLayouts(directory))?.[TAB]?.root)
}
function fakeRuntime() {
const rekeyWorkerTerminalResourcePaneKey = vi.fn(() => 1)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the repair reads only the orchestration DB accessor.
const runtime = {
getExistingOrchestrationDb: () => ({ rekeyWorkerTerminalResourcePaneKey })
} as unknown as OrcaRuntimeService
return { runtime, rekeyWorkerTerminalResourcePaneKey }
}
function workspaceSessionWrites(authority: DelayedAuthority): number {
return authority.captures.filter((writes) =>
writes.some((write) => JSON.stringify(write).includes('workspaceSession'))
).length
}
describe('duplicate-terminal pane repair at load (layout-owner builds)', () => {
it('moves the dropped pane’s agent status and orchestration row, then writes one pane', async () => {
const directory = seededDirectory()
const { store } = await load(directory)
const diskAtTransfer: string[][] = []
const transfer = vi
.spyOn(agentHookServer, 'transferPaneAuthority')
.mockImplementation(() => diskAtTransfer.push(storedLeafIds(directory)))
const { runtime, rekeyWorkerTerminalResourcePaneKey } = fakeRuntime()
await repairDuplicatePtyPanesOnStart(store, runtime)
expect(transfer).toHaveBeenCalledExactlyOnceWith(
`${TAB}:${DUPLICATE}`,
`${TAB}:${KEPT}`,
'pty-1',
expect.any(Number),
{ authorityVerified: true }
)
expect(rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledExactlyOnceWith({
fromPaneKey: `${TAB}:${DUPLICATE}`,
toPaneKey: `${TAB}:${KEPT}`
})
// The status moved while disk still held both panes.
expect(diskAtTransfer).toEqual([[KEPT, DUPLICATE]])
await quit(store)
expect(storedLeafIds(directory)).toEqual([KEPT])
expect(JSON.parse(storedLayouts(directory))[TAB].ptyIdsByLeafId).toEqual({ [KEPT]: 'pty-1' })
})
it('leaves disk alone with no runtime, then a runtime start transfers and writes once', async () => {
const directory = seededDirectory()
const seeded = storedLayouts(directory)
const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
// A load no runtime starts on (a CLI-only read): it reads the repair, never writes it.
const cliOnly = await load(directory)
await quit(cliOnly.store)
expect(storedLayouts(directory)).toBe(seeded)
expect(transfer).not.toHaveBeenCalled()
const started = await load(directory)
started.authority.captures.length = 0
const { runtime, rekeyWorkerTerminalResourcePaneKey } = fakeRuntime()
await repairDuplicatePtyPanesOnStart(started.store, runtime)
expect(transfer).toHaveBeenCalledOnce()
expect(rekeyWorkerTerminalResourcePaneKey).toHaveBeenCalledOnce()
expect(workspaceSessionWrites(started.authority)).toBe(1)
expect(storedLeafIds(directory)).toEqual([KEPT])
})
it('neither transfers nor writes on a frozen or imported Store', async () => {
const directory = seededDirectory()
const seeded = storedLayouts(directory)
const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
const { store } = await load(directory)
await store.freezeWritesAsync()
const imported = new Store({
serializedState: JSON.stringify({
...JSON.parse(emptyTerminalSessionProfile()),
workspaceSession: duplicateSession()
})
})
await repairDuplicatePtyPanesOnStart(store, fakeRuntime().runtime)
await repairDuplicatePtyPanesOnStart(imported, fakeRuntime().runtime)
expect(transfer).not.toHaveBeenCalled()
expect(storedLayouts(directory)).toBe(seeded)
expect(leafIds(imported.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root)).toEqual([
KEPT,
DUPLICATE
])
})
it('is a no-op on the next start', async () => {
const directory = seededDirectory()
const first = await load(directory)
const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
await repairDuplicatePtyPanesOnStart(first.store, fakeRuntime().runtime)
await quit(first.store)
transfer.mockClear()
const repaired = storedLayouts(directory)
const second = await load(directory)
second.authority.captures.length = 0
await expect(second.store.repairDuplicatePtyPanes(() => true)).resolves.toBe(0)
expect(transfer).not.toHaveBeenCalled()
expect(workspaceSessionWrites(second.authority)).toBe(0)
expect(storedLayouts(directory)).toBe(repaired)
})
it('moves the dropped pane’s sleep record, read marks, incarnation and SSH lease to the kept pane', async () => {
const directory = seededDirectory(withDroppedPaneRecords)
const { store } = await load(directory)
vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
await repairDuplicatePtyPanesOnStart(store, fakeRuntime().runtime)
await quit(store)
const state = storedState(directory)
const session = state.workspaceSession
expect(Object.keys(session?.sleepingAgentSessionsByPaneKey ?? {})).toEqual([KEPT_KEY])
expect(session?.sleepingAgentSessionsByPaneKey?.[KEPT_KEY]?.paneKey).toBe(KEPT_KEY)
expect(session?.terminalPtyIncarnationsByPaneKey).toEqual({ [KEPT_KEY]: 'inc-1' })
expect(state.ui?.acknowledgedAgentsByPaneKey).toEqual({ [KEPT_KEY]: 11 })
expect(state.ui?.activityClearedAtByPaneKey).toEqual({ [KEPT_KEY]: 12 })
expect(state.ui?.manuallyUnreadTurnsByPaneKey).toEqual({ [KEPT_KEY]: 13 })
expect(state.sshRemotePtyLeases?.map(({ tabId, leafId }) => ({ tabId, leafId }))).toEqual([
{ tabId: TAB, leafId: KEPT }
])
})
it('keeps the kept pane’s own incarnation over the dropped pane’s', async () => {
const directory = seededDirectory((_state, session) => {
session.terminalPtyIncarnationsByPaneKey = { [DROPPED_KEY]: 'inc-old', [KEPT_KEY]: 'inc-1' }
})
const { store } = await load(directory)
vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
await repairDuplicatePtyPanesOnStart(store, fakeRuntime().runtime)
await quit(store)
expect(storedState(directory).workspaceSession?.terminalPtyIncarnationsByPaneKey).toEqual({
[KEPT_KEY]: 'inc-1'
})
})
it('leaves a status owner that already moved to another tab, and its records, where they are', async () => {
const directory = seededDirectory(withDroppedPaneRecords)
const { store } = await load(directory)
const transfer = vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
vi.spyOn(agentHookServer, 'resolvePaneAuthorityOwner').mockImplementation((paneKey) =>
paneKey === DROPPED_KEY ? `tab-2:${KEPT}` : paneKey
)
const { runtime, rekeyWorkerTerminalResourcePaneKey } = fakeRuntime()
await repairDuplicatePtyPanesOnStart(store, runtime)
await quit(store)
expect(transfer).not.toHaveBeenCalled()
expect(rekeyWorkerTerminalResourcePaneKey).not.toHaveBeenCalled()
const state = storedState(directory)
expect(storedLeafIds(directory)).toEqual([KEPT])
expect(Object.keys(state.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {})).toEqual([
DROPPED_KEY
])
expect(state.ui?.acknowledgedAgentsByPaneKey).toEqual({ [DROPPED_KEY]: 11 })
})
it('writes nothing when a pane owner move fails, so the next start retries', async () => {
const directory = seededDirectory()
const seeded = storedLayouts(directory)
const { store } = await load(directory)
vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the repair reads only the orchestration DB accessor.
const runtime = {
getExistingOrchestrationDb: () => ({
rekeyWorkerTerminalResourcePaneKey: () => {
throw new Error('database is locked')
}
})
} as unknown as OrcaRuntimeService
await repairDuplicatePtyPanesOnStart(store, runtime)
await quit(store)
expect(storedLayouts(directory)).toBe(seeded)
})
})
describe('planDuplicatePtyPaneRepair', () => {
it('repairs the layout but keeps the owner when another tab also binds the terminal', () => {
const session = duplicateSession()
session.terminalLayoutsByTabId['tab-2'] = {
root: { type: 'leaf', leafId: '33333333-3333-4333-8333-333333333333' },
activeLeafId: '33333333-3333-4333-8333-333333333333',
expandedLeafId: null,
ptyIdsByLeafId: { '33333333-3333-4333-8333-333333333333': 'pty-1' }
}
const plan = planDuplicatePtyPaneRepair(session)
expect(leafIds(plan?.session.terminalLayoutsByTabId[TAB]?.root)).toEqual([KEPT])
expect(plan?.transfers).toEqual([])
})
})
describe('planDuplicatePtyPaneRepair on a paused WSL move', () => {
it('leaves a frozen workspace’s tree and owners alone, since the write keeps its stored tree', () => {
expect(
planDuplicatePtyPaneRepair(duplicateSession(), (id) => id === FIXTURE_GIT_WORKTREE_ID)
).toBeNull()
})
})
@@ -0,0 +1,162 @@
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import {
normalizeTerminalLayoutPtyOwnership,
resolveTerminalLayoutPtyOwnershipTransfers
} from '../../../shared/terminal-layout-pty-ownership'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state'
import { isWslMoveWorkspaceSessionFrozen } from '../moving-wsl-projects/wsl-move-scope-guard'
import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration'
import { writeRestorable } from './restorable-write'
import {
moveRecordKey,
rekeyPaneProfileRecords,
writeRekeyedProfileRecords
} from './terminal-leaf-move'
import { keepFrozen, type TerminalTopologyCommitContext } from './terminal-topology-commit'
import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
/** A pane the repair drops, and the pane of the same tab that keeps its terminal. */
export type DuplicatePtyPaneTransfer = {
worktreeId: string
tabId: string
removedLeafId: string
retainedLeafId: string
ptyId: string
}
function boundByOtherTab(session: WorkspaceSessionState, tabId: string, ptyId: string): boolean {
return Object.entries(session.terminalLayoutsByTabId ?? {}).some(
([otherTabId, layout]) =>
otherTabId !== tabId && Object.values(layout?.ptyIdsByLeafId ?? {}).includes(ptyId)
)
}
/** The partition with one pane per terminal, and the owner transfers that implies; null when none. */
export function planDuplicatePtyPaneRepair(
session: WorkspaceSessionState,
/** A workspace whose stored session must stay as it is (a paused WSL move). */
isFrozen: (worktreeId: string) => boolean = () => false
): { session: WorkspaceSessionState; transfers: DuplicatePtyPaneTransfer[] } | null {
let layouts: Record<string, TerminalLayoutSnapshot> | null = null
const transfers: DuplicatePtyPaneTransfer[] = []
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const repaired = normalizeTerminalLayoutPtyOwnership(layout)
const worktreeId = findWorktreeIdForTab(session, tabId)
// The write keeps a frozen workspace's stored tree, so its owners must not move either.
if (!repaired.changed || (worktreeId && isFrozen(worktreeId))) {
continue
}
layouts ??= { ...session.terminalLayoutsByTabId }
layouts[tabId] = repaired.snapshot
for (const transfer of resolveTerminalLayoutPtyOwnershipTransfers(layout, repaired.snapshot)) {
// The window's transfer guards: only pane keys that parse, and no terminal another tab binds.
if (
worktreeId &&
!tabId.includes(':') &&
isTerminalLeafId(transfer.removedLeafId) &&
isTerminalLeafId(transfer.retainedLeafId) &&
!boundByOtherTab(session, tabId, transfer.ptyId)
) {
transfers.push({ worktreeId, tabId, ...transfer })
}
}
}
return layouts ? { session: { ...session, terminalLayoutsByTabId: layouts }, transfers } : null
}
/** The dropped pane's sleep record and incarnation follow it, as the window's transfer moves them. */
function rekeyPaneSessionRecords(
session: WorkspaceSessionState,
move: DuplicatePtyPaneTransfer
): WorkspaceSessionState {
const from = makePaneKey(move.tabId, move.removedLeafId)
const to = makePaneKey(move.tabId, move.retainedLeafId)
return {
...session,
// Both panes name one terminal, so the kept pane's own incarnation wins.
terminalPtyIncarnationsByPaneKey: moveRecordKey(
session.terminalPtyIncarnationsByPaneKey,
from,
to,
undefined,
true
),
sleepingAgentSessionsByPaneKey: moveRecordKey(
session.sleepingAgentSessionsByPaneKey,
from,
to,
(record) => ({ ...record, paneKey: to })
)
}
}
/**
* Repairs every non-runtime partition holding a terminal in two panes of one tab. Every transfer
* runs before the write, so disk never drops a pane key whose owners have not moved yet. `transfer`
* returns false for a pane whose owner is not this tab's to move (its records stay), and throws
* when a move failed, which skips the write so the next start retries.
*/
export function repairDuplicatePtyPanesMutation(
context: TerminalTopologyCommitContext,
transfer: (move: DuplicatePtyPaneTransfer) => boolean
): () => DurableProfileStateMutation<number> {
return () => {
const plans = context
.hostIds()
// A paired server's partition is a display copy of that server's own layout.
.filter((hostId) => !hostId.startsWith('runtime:'))
.flatMap((hostId: ExecutionHostId) => {
const plan = planDuplicatePtyPaneRepair(
context.getSession(hostId),
(worktreeId) =>
hostId === LOCAL_EXECUTION_HOST_ID && isWslMoveWorkspaceSessionFrozen(worktreeId)
)
return plan ? [{ hostId, ...plan }] : []
})
if (plans.length === 0) {
return { value: 0, persist: false }
}
let moved: Set<DuplicatePtyPaneTransfer>
try {
moved = new Set(plans.flatMap((plan) => plan.transfers.filter(transfer)))
} catch (error) {
console.warn('[persistence] duplicate-terminal pane repair skipped:', error)
return { value: 0, persist: false }
}
const restores = plans.map(({ hostId, session, transfers }) =>
writeRestorable(
() => context.getSession(hostId),
(value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)),
keepFrozen(
hostId,
transfers.filter((move) => moved.has(move)).reduce(rekeyPaneSessionRecords, session),
context
)
)
)
for (const move of moved) {
restores.push(
...writeRekeyedProfileRecords(
rekeyPaneProfileRecords(
context.state,
{ tabId: move.tabId, leafId: move.removedLeafId },
{ tabId: move.tabId, leafId: move.retainedLeafId }
),
context
)
)
}
return {
value: plans.length,
// Latest write first: a restore only undoes a value no later write replaced.
rollback: () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
}
}
@@ -8,10 +8,14 @@ import {
} from '../loading-store/session-host-partitions'
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
import { applyLayout, topologyCommitContext } from './terminal-topology-commit'
import {
repairDuplicatePtyPanesMutation,
type DuplicatePtyPaneTransfer
} from './duplicate-pty-pane-repair'
type LayoutCommandRuntime = Pick<
StoreRuntimeState,
'runDurableMutation' | 'state' | 'dirtyProfileStateDomains'
'runDurableMutation' | 'state' | 'dirtyProfileStateDomains' | 'writesFrozen'
>
const layoutCommandPersistenceContext = Symbol('LayoutCommandPersistence')
@@ -39,4 +43,19 @@ export class LayoutCommandPersistence {
applyLayout(resolved, command, topologyCommitContext(context))
)
}
/**
* Layout-owner builds, once a runtime starts: one pane per terminal on disk, written only after
* `transfer` moved each dropped pane's owners. Resolves the number of partitions repaired.
*/
repairDuplicatePtyPanes(transfer: (move: DuplicatePtyPaneTransfer) => boolean): Promise<number> {
const context = this[layoutCommandPersistenceContext]
// An imported or frozen profile neither writes nor moves owners.
if (context.runtime.writesFrozen) {
return Promise.resolve(0)
}
return context.runtime.runDurableMutation(
repairDuplicatePtyPanesMutation(topologyCommitContext(context), transfer)
)
}
}
@@ -1,5 +1,6 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
terminalLeafMovePaneKeys,
type TerminalLeafMoveRequest,
@@ -24,11 +25,13 @@ type PlannedTerminalLeafMove = {
sessions: TerminalSessionPartition[]
}
function moveRecordKey<T>(
export function moveRecordKey<T>(
record: Record<string, T> | undefined,
fromKey: string,
toKey: string | null,
remap: (value: T) => T = (value) => value
remap: (value: T) => T = (value) => value,
/** Keep a value already at `toKey`; the moved one is dropped. */
keepTarget = false
): Record<string, T> | undefined {
if (!record || !Object.hasOwn(record, fromKey)) {
return record
@@ -36,7 +39,7 @@ function moveRecordKey<T>(
const next = { ...record }
const value = next[fromKey]
delete next[fromKey]
if (toKey !== null && value !== undefined) {
if (toKey !== null && value !== undefined && !(keepTarget && Object.hasOwn(next, toKey))) {
next[toKey] = remap(value)
}
return next
@@ -208,12 +211,16 @@ const PANE_KEYED_UI_RECORDS = [
'manuallyUnreadTurnsByPaneKey'
] as const
/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a moved leaf. */
export function rekeyMovedLeafProfileRecords(
type PaneAddress = { tabId: string; leafId: string }
/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a pane to its new key. */
export function rekeyPaneProfileRecords(
state: Pick<PersistedState, 'ui' | 'sshRemotePtyLeases'>,
move: TerminalLeafMoveRequest
from: PaneAddress,
to: PaneAddress
): { ui?: PersistedState['ui']; sshRemotePtyLeases?: SshRemotePtyLease[] } {
const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(move)
const fromPaneKey = makePaneKey(from.tabId, from.leafId)
const toPaneKey = makePaneKey(to.tabId, to.leafId)
const ui = state.ui
let nextUi: PersistedState['ui'] | undefined
for (const key of PANE_KEYED_UI_RECORDS) {
@@ -224,15 +231,15 @@ export function rekeyMovedLeafProfileRecords(
}
const leases = state.sshRemotePtyLeases ?? []
const leasesChanged = leases.some(
(lease) => lease.tabId === move.sourceTabId && lease.leafId === move.leafId
(lease) => lease.tabId === from.tabId && lease.leafId === from.leafId
)
return {
...(nextUi ? { ui: nextUi } : {}),
...(leasesChanged
? {
sshRemotePtyLeases: leases.map((lease) =>
lease.tabId === move.sourceTabId && lease.leafId === move.leafId
? { ...lease, tabId: move.targetTabId }
lease.tabId === from.tabId && lease.leafId === from.leafId
? { ...lease, tabId: to.tabId, leafId: to.leafId }
: lease
)
}
@@ -240,14 +247,33 @@ export function rekeyMovedLeafProfileRecords(
}
}
/** Pane-keyed UI marks and SSH lease leaf addresses that must follow a moved leaf. */
export function rekeyMovedLeafProfileRecords(
state: Pick<PersistedState, 'ui' | 'sshRemotePtyLeases'>,
move: TerminalLeafMoveRequest
): { ui?: PersistedState['ui']; sshRemotePtyLeases?: SshRemotePtyLease[] } {
return rekeyPaneProfileRecords(
state,
{ tabId: move.sourceTabId, leafId: move.leafId },
{ tabId: move.targetTabId, leafId: move.leafId }
)
}
/** Pane-keyed UI marks and SSH leases follow a moved leaf; returns their restores. */
export function writeMovedLeafProfileRecords(
request: TerminalLeafMoveRequest,
context: TerminalTopologyCommitContext
): (() => void)[] {
return writeRekeyedProfileRecords(rekeyMovedLeafProfileRecords(context.state, request), context)
}
/** Writes rekeyed UI marks and SSH leases; returns their restores. */
export function writeRekeyedProfileRecords(
rekeyed: ReturnType<typeof rekeyPaneProfileRecords>,
context: TerminalTopologyCommitContext
): (() => void)[] {
const { state } = context
const restores: (() => void)[] = []
const rekeyed = rekeyMovedLeafProfileRecords(state, request)
if (rekeyed.ui) {
restores.push(
writeRestorable(
@@ -200,7 +200,7 @@ export function topologyCommitContext({
}
// A paused WSL move keeps its local rows as the quiesce left them.
function keepFrozen(
export function keepFrozen(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
context: TerminalTopologyCommitContext
+68 -25
View File
@@ -1,35 +1,35 @@
import { agentHookServer } from '../agent-hooks/server'
import { forgetFailedAgentLaunches } from '../agent-launch/agent-launch-pane-attachment'
import {
terminalLeafMovePaneKeys,
type TerminalLeafMoveRequest
} from '../../shared/terminal-leaf-move'
import type { Store } from '../persistence/loading-store/store'
import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import type { TerminalLeafMoveRequest } from '../../shared/terminal-leaf-move'
import type { OrcaRuntimeService } from './orca-runtime'
/** After a committed move of a pane into a new tab: agent-status and orchestration keys follow. */
export function rekeyMovedPaneOwners(
runtime: Pick<OrcaRuntimeService, 'getExistingOrchestrationDb'> | undefined,
/** `ptyId` is the terminal the committed move bound the pane to. */
request: TerminalLeafMoveRequest
): void {
const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request)
forgetFailedAgentLaunches(request.worktreeId, {
kind: 'pane',
tabId: request.sourceTabId,
leafId: request.leafId
})
type PaneOwnerRuntime = Pick<OrcaRuntimeService, 'getExistingOrchestrationDb'> | undefined
type PaneAddress = { tabId: string; leafId: string }
/**
* After a committed pane-key change: agent-status and orchestration keys follow the pane. A
* failure is logged and the pane keeps that old key; returns false when either step failed.
*/
function rekeyPaneOwners(
runtime: PaneOwnerRuntime,
worktreeId: string,
from: PaneAddress,
to: PaneAddress,
ptyId: string | null
): boolean {
let moved = true
const fromPaneKey = makePaneKey(from.tabId, from.leafId)
const toPaneKey = makePaneKey(to.tabId, to.leafId)
forgetFailedAgentLaunches(worktreeId, { kind: 'pane', ...from })
try {
// The process keeps the pane key baked into its env, so status must alias old to new.
agentHookServer.transferPaneAuthority(
fromPaneKey,
toPaneKey,
request.ptyId ?? undefined,
Date.now(),
{
authorityVerified: true
}
)
agentHookServer.transferPaneAuthority(fromPaneKey, toPaneKey, ptyId ?? undefined, Date.now(), {
authorityVerified: true
})
} catch (error) {
moved = false
console.warn('[pty] moved pane kept its old agent-status key:', error)
}
try {
@@ -38,6 +38,49 @@ export function rekeyMovedPaneOwners(
toPaneKey
})
} catch (error) {
moved = false
console.warn('[pty] moved pane kept its old orchestration resource key:', error)
}
return moved
}
/** After a committed move of a pane into a new tab: agent-status and orchestration keys follow. */
export function rekeyMovedPaneOwners(
runtime: PaneOwnerRuntime,
/** `ptyId` is the terminal the committed move bound the pane to. */
request: TerminalLeafMoveRequest
): void {
rekeyPaneOwners(
runtime,
request.worktreeId,
{ tabId: request.sourceTabId, leafId: request.leafId },
{ tabId: request.targetTabId, leafId: request.leafId },
request.ptyId
)
}
/** Layout-owner builds: main's one load-time repair, once this runtime can move pane owners. */
export async function repairDuplicatePtyPanesOnStart(
store: Pick<Store, 'repairDuplicatePtyPanes'>,
runtime: PaneOwnerRuntime
): Promise<void> {
try {
await store.repairDuplicatePtyPanes((move) => {
const from = { tabId: move.tabId, leafId: move.removedLeafId }
// The window's rule: a status owner that already moved to another tab stays there.
const owner = parsePaneKey(
agentHookServer.resolvePaneAuthorityOwner(makePaneKey(from.tabId, from.leafId))
)
if (owner && owner.tabId !== move.tabId) {
return false
}
const to = { tabId: move.tabId, leafId: move.retainedLeafId }
if (!rekeyPaneOwners(runtime, move.worktreeId, from, to, move.ptyId)) {
throw new Error('pane_owner_transfer_failed')
}
return true
})
} catch (error) {
console.warn('[persistence] duplicate-terminal pane repair was not written:', error)
}
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
TEST_WORKTREE_ID,
makeHeadlessTerminalLayout,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} from '../orca-runtime-test-fixtures.spec'
import { planDuplicatePtyPaneRepair } from '../../persistence/terminal-topology/duplicate-pty-pane-repair'
import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec'
// The windowless list reads main's stored session directly, so a terminal stored in two panes
// of one tab is listed twice until main repairs the stored tree.
const duplicate = makeWorkspaceSessionWithHeadlessTerminal({
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({
[HEADLESS_LEAF_ID]: 'persisted-pty',
[HEADLESS_SECOND_LEAF_ID]: 'persisted-pty'
})
}
})
async function terminalRows(session: WorkspaceSessionState): Promise<number> {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
const snapshots = await runtime.listAllMobileSessionTabs()
return (snapshots.find((snapshot) => snapshot.worktree === TEST_WORKTREE_ID)?.tabs ?? []).filter(
(tab) => tab.type === 'terminal'
).length
}
describe('windowless mobile list of a terminal stored in two panes', () => {
it('lists the stored duplicate twice (the reader that skips the Loader)', async () => {
await expect(terminalRows(duplicate)).resolves.toBe(2)
})
it('lists one row once main has repaired the stored tree', async () => {
await expect(terminalRows(planDuplicatePtyPaneRepair(duplicate)!.session)).resolves.toBe(1)
})
})
+1
View File
@@ -87,6 +87,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec')
await import('./orca-runtime-tests/mobile-session-tabs-part-14.spec')
await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec')
await import('./orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec')
await import('./orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec')
await import('./orca-runtime-tests/mobile-session-tabs-headless-chat-rebuild.spec')
await import('./orca-runtime-tests/mobile-session-tabs-shell-selection.spec')
await import('./orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec')
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalOrphanAdoptionRequest
} from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
GIT_KEY,
addWorkspace,
emptySession,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { planDuplicatePtyPaneRepair } from '../persistence/terminal-topology/duplicate-pty-pane-repair'
import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
const TAB = 'tab-1'
const KEPT = leaf(1)
const DUPLICATE = leaf(2)
function duplicateSession(): WorkspaceSessionState {
const session = addWorkspace(emptySession(), GIT_KEY, [
{
id: 'group-1',
tabs: [
{
id: TAB,
leaves: [
[KEPT, 'pty-1'],
[DUPLICATE, 'pty-1']
]
}
]
}
])
return { ...session, terminalPtyIncarnationsByPaneKey: { [`${TAB}:${KEPT}`]: 'inc-1' } }
}
function adoptKeptPane(session: WorkspaceSessionState) {
const request: RuntimeTerminalOrphanAdoptionRequest = {
worktree: GIT_KEY,
expectedTopologyRevision: 0,
claims: [
{ terminal: 'term-1', ptyId: 'pty-1', incarnationId: 'inc-1', tabId: TAB, leafId: KEPT }
]
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption reads only these PTY fields before the persisted-binding check.
const pty = {
ptyId: 'pty-1',
incarnationId: 'inc-1',
worktreeId: GIT_KEY,
connectionId: null,
connected: true,
tabId: null,
paneKey: null
} as RuntimePtyWorktreeRecord
const replayPersistedSurface = vi.fn()
const adopted = adoptRuntimeTerminalOrphansFromInventory({
hostId: LOCAL_EXECUTION_HOST_ID,
request,
workspace: {
id: GIT_KEY,
path: '/Users/dev/orca',
connectionId: null,
repo: null,
folderWorkspace: null
},
inventory: {
livePtyIds: new Set(['pty-1']),
allLivePtyIds: new Set(['pty-1']),
terminalIdentityByPtyId: new Map([
['pty-1', { handle: 'term-1', incarnationId: 'inc-1', wslDistro: null }]
]),
queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID])
},
session,
sessionWorktreeId: GIT_KEY,
repoId: 'repo-1',
worktreeWslDistro: null,
currentRevision: 0,
ports: {
getPty: () => pty,
getLeaves: () => [],
getLeaf: () => undefined,
getDisplayTitle: () => null,
replayPersistedSurface,
recordAdoptedSurface: vi.fn(),
getMobileSnapshots: () => [],
getSession: () => session,
setSession: vi.fn(),
flushSession: async () => {},
hydrateSession: vi.fn(),
notifySessionChanged: vi.fn(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the replay path returns this value untouched.
getSnapshot: () => ({}) as RuntimeMobileSessionTabsResult
}
})
return { adopted, replayPersistedSurface }
}
describe('orphan adoption of a terminal main repaired at load', () => {
it('refuses the stored duplicate as a competing owner (the reader that skips the Loader)', async () => {
await expect(adoptKeptPane(duplicateSession()).adopted).rejects.toThrow(
'terminal_orphan_competing_owner'
)
})
it('replays the kept pane once the stored tree holds one pane per terminal', async () => {
const repaired = planDuplicatePtyPaneRepair(duplicateSession())!.session
const { adopted, replayPersistedSurface } = adoptKeptPane(repaired)
await expect(adopted).resolves.toMatchObject({ adopted: false })
expect(replayPersistedSurface).toHaveBeenCalledWith(expect.anything(), TAB, `${TAB}:${KEPT}`)
})
})
@@ -42,6 +42,8 @@ import {
} from '../runtime/agent-status-observed-pane-identity'
import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update'
import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { repairDuplicatePtyPanesOnStart } from '../runtime/moved-pane-owner-rekey'
export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus {
const activation = state.desktopActivationGate
@@ -146,6 +148,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
recordDurableCrashBreadcrumb('agent_state_rules_active', rules)
)
state.runtime = runtime
// Switch point: main's load repair is written only now that a runtime can move pane owners.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
void repairDuplicatePtyPanesOnStart(store, runtime)
}
runtime.sidebarFacts?.attachSidebarAgentStatusStore(agentHookServer)
agentHookServer.subscribeEnrichedStatus((enriched) =>
recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)