mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user