mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(layout): main shows live terminals no pane shows and derives what the next startup reconnects (dark, layout-owner build only) (#31118)
Main runs one pass at startup and each window load, with or without a window (desktop, orca serve, orcad): every live local terminal no pane names is bound to the pane its record names while main still holds it free, else shown in a new tab bound to it. The window no longer asks main to adopt terminals; it only reads that main holds them. The window's layout.apply loses its adopt field. Main also derives activeWorktreeIdsOnShutdown, remoteSessionIdsByTabId and activeConnectionIdsAtShutdown from its own layout, terminal records and SSH connections, replacing the window's copies in every save and the quit checkpoint. Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"../src/main/persistence/tracking-repos/worktree-identity-row-repoint.ts",
|
||||
"../src/main/persistence/restoring-sessions/tab-terminal-pane-binding-repair.ts",
|
||||
"../src/main/persistence/window-save-view.ts",
|
||||
"../src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts",
|
||||
"../src/main/persistence/applying-settings/ui-state-update.ts",
|
||||
"../src/main/persistence/applying-settings/ui-selection-normalization.ts",
|
||||
"../src/main/persistence/applying-settings/ui-interaction-merge.ts",
|
||||
|
||||
@@ -183,7 +183,11 @@ describe('registerAppHandlers', () => {
|
||||
|
||||
registerAppHandlers(store as never)
|
||||
|
||||
expect(registerRendererShutdownCheckpointHandlerMock).toHaveBeenCalledWith(store, undefined)
|
||||
expect(registerRendererShutdownCheckpointHandlerMock).toHaveBeenCalledWith(
|
||||
store,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(registerMacKeyboardLayoutChangeNotificationsMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
+8
-1
@@ -36,8 +36,11 @@ import { isProfileStateSaveDelayed } from '../startup/profile-state-save-delay'
|
||||
import { readCommandStdout, readKeyboardInputSourceId } from './macos-keyboard-input-source'
|
||||
import { getWindowsAdministratorStatus } from '../windows/windows-administrator-status'
|
||||
|
||||
import type { ReconnectFactsFor } from '../persistence/window-save-view'
|
||||
|
||||
type RegisterAppHandlersOptions = {
|
||||
quitResumeRecordsFor?: QuitResumeRecordsFor
|
||||
reconnectFactsFor?: ReconnectFactsFor
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
/** Only for app:relaunch, whose app.exit(0) is committed and skips will-quit. */
|
||||
onBeforeRelaunchExit?: () => void | Promise<void>
|
||||
@@ -115,7 +118,11 @@ function resolveDevFeatureWallAssetDir(): string {
|
||||
}
|
||||
|
||||
export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void {
|
||||
registerRendererShutdownCheckpointHandler(store, options.quitResumeRecordsFor)
|
||||
registerRendererShutdownCheckpointHandler(
|
||||
store,
|
||||
options.quitResumeRecordsFor,
|
||||
options.reconnectFactsFor
|
||||
)
|
||||
registerMacKeyboardLayoutChangeNotifications()
|
||||
ipcMain.handle('app:isProfileStateSaveDelayed', isProfileStateSaveDelayed)
|
||||
ipcMain.handle('app:getWindowsAdministratorStatus', getWindowsAdministratorStatus)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app } from 'electron'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { registerAppHandlers } from '../app'
|
||||
import { registerCliHandlers } from '../cli'
|
||||
import { registerPreflightHandlers } from '../preflight'
|
||||
@@ -169,6 +170,13 @@ export function registerCoreHandlers(
|
||||
|
||||
registerAppHandlers(store, {
|
||||
quitResumeRecordsFor: (session) => runtime.quitResumeRecordsFor(session),
|
||||
// Switch point: main derives what the next startup reconnects instead of taking the window's.
|
||||
...(ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? {
|
||||
reconnectFactsFor: (session, partition) =>
|
||||
runtime.shutdownReconnectFactsFor(session, partition)
|
||||
}
|
||||
: {}),
|
||||
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch,
|
||||
// Why here, not onBeforeRelaunch: restart and profile switches quit through will-quit, which closes it.
|
||||
onBeforeRelaunchExit: () => closeNotificationDismissalsBeforeRelaunch(runtime)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source'
|
||||
import { windowViewSaveForMain } from '../persistence/window-save-view'
|
||||
import { windowViewSaveForMain, type ReconnectFactsFor } from '../persistence/window-save-view'
|
||||
import { reportWindowSaveDrift } from '../persistence/window-save-divergence'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
|
||||
@@ -90,17 +90,19 @@ function stagedSessionForMain(
|
||||
store: Store,
|
||||
state: WorkspaceSessionState,
|
||||
hostId: ExecutionHostId | undefined,
|
||||
quitResumeRecordsFor: QuitResumeRecordsFor
|
||||
quitResumeRecordsFor: QuitResumeRecordsFor,
|
||||
reconnectFactsFor: ReconnectFactsFor | undefined
|
||||
): WorkspaceSessionState {
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
const stored = withQuitSleepRecords(hostId, session, quitResumeRecordsFor(session))
|
||||
reportWindowSaveDrift(stored, state, hostId, 'shutdown-checkpoint')
|
||||
return windowViewSaveForMain(stored, state, hostId)
|
||||
return windowViewSaveForMain(stored, state, hostId, reconnectFactsFor)
|
||||
}
|
||||
|
||||
export function registerRendererShutdownCheckpointHandler(
|
||||
store: Store,
|
||||
quitResumeRecordsFor: QuitResumeRecordsFor = () => []
|
||||
quitResumeRecordsFor: QuitResumeRecordsFor = () => [],
|
||||
reconnectFactsFor?: ReconnectFactsFor
|
||||
): void {
|
||||
// Why: beforeunload cannot await, so the sync reply only reports staging.
|
||||
// Durability is joined out-of-band by paths that are about to navigate.
|
||||
@@ -113,7 +115,7 @@ export function registerRendererShutdownCheckpointHandler(
|
||||
if (!isFrozenOrcadSourceSessionPartition(store, hostId)) {
|
||||
store.stageWorkspaceSessionBeforeUnload(
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? stagedSessionForMain(store, state, hostId, quitResumeRecordsFor)
|
||||
? stagedSessionForMain(store, state, hostId, quitResumeRecordsFor, reconnectFactsFor)
|
||||
: state,
|
||||
hostId
|
||||
)
|
||||
|
||||
@@ -89,8 +89,16 @@ describe('a window save in a layout-owner build', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
||||
store = createStore()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the save handlers never reach the runtime.
|
||||
registerSessionHandlers(store, {} as never)
|
||||
// Main's reconnect facts are pinned elsewhere (window-save-reconnect-facts.test.ts): kept as saved.
|
||||
const runtime = {
|
||||
shutdownReconnectFactsFor: (session: WorkspaceSessionState) => ({
|
||||
activeWorktreeIdsOnShutdown: session.activeWorktreeIdsOnShutdown,
|
||||
remoteSessionIdsByTabId: session.remoteSessionIdsByTabId,
|
||||
activeConnectionIdsAtShutdown: session.activeConnectionIdsAtShutdown
|
||||
})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the save handlers reach only this runtime method.
|
||||
registerSessionHandlers(store, runtime as never)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await closeTestStores()
|
||||
|
||||
@@ -36,12 +36,16 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
|
||||
const sessionForMain = (args: WorkspaceSessionState, hostId: string | null | undefined) => {
|
||||
const stored = store.getWorkspaceSession(hostId)
|
||||
reportWindowSaveDrift(stored, args, hostId, 'session:set')
|
||||
return windowViewSaveForMain(stored, args, hostId)
|
||||
return windowViewSaveForMain(stored, args, hostId, (session, partition) =>
|
||||
runtime.shutdownReconnectFactsFor(session, partition)
|
||||
)
|
||||
}
|
||||
const patchForMain = (args: WorkspaceSessionPatch, hostId: string | null | undefined) => {
|
||||
const stored = store.getWorkspaceSession(hostId)
|
||||
reportWindowSaveDrift(stored, { ...stored, ...args }, hostId, 'session:patch')
|
||||
return windowViewPatchForMain(stored, args, hostId)
|
||||
return windowViewPatchForMain(stored, args, hostId, (session, partition) =>
|
||||
runtime.shutdownReconnectFactsFor(session, partition)
|
||||
)
|
||||
}
|
||||
|
||||
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => {
|
||||
|
||||
@@ -26,12 +26,15 @@ const state = vi.hoisted(() => {
|
||||
rekeyWorkerTerminalResourcePaneKey: Mock<() => number>
|
||||
/** What a startup step saw on disk; set by a test that watches the order. */
|
||||
onStartupStep: ((step: string) => void) | null
|
||||
/** The startup pass that shows live terminals fails outright. */
|
||||
placementRejects: boolean
|
||||
} = {
|
||||
root: '',
|
||||
store: null,
|
||||
layoutOwnerIsMain: true,
|
||||
rekeyWorkerTerminalResourcePaneKey: vi.fn(() => 1),
|
||||
onStartupStep: null
|
||||
onStartupStep: null,
|
||||
placementRejects: false
|
||||
}
|
||||
return value
|
||||
})
|
||||
@@ -146,6 +149,13 @@ vi.mock('../runtime/orca-runtime', () => ({
|
||||
async reconcileLegacyWorkerTerminals() {
|
||||
state.onStartupStep?.('reconcile')
|
||||
}
|
||||
async placeUnshownLiveTerminals() {
|
||||
state.onStartupStep?.('place')
|
||||
if (state.placementRejects) {
|
||||
throw new Error('census record unreadable')
|
||||
}
|
||||
return { placed: [], declined: [] }
|
||||
}
|
||||
async installPendingOrcadMigrationShellHistory() {}
|
||||
async stopLegacyWorkerTerminalRecovery() {}
|
||||
async closeNotificationDismissals() {}
|
||||
@@ -177,6 +187,7 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
state.layoutOwnerIsMain = true
|
||||
state.onStartupStep = null
|
||||
state.placementRejects = false
|
||||
state.rekeyWorkerTerminalResourcePaneKey.mockClear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
@@ -303,7 +314,24 @@ it('writes the repair before reconcile and RPC even when a profile write is in f
|
||||
await heldWrite
|
||||
const host = await startup
|
||||
try {
|
||||
expect(diskAtStep).toEqual({ reconcile: [KEPT], 'rpc.start': [KEPT] })
|
||||
expect(diskAtStep).toEqual({ reconcile: [KEPT], place: [KEPT], 'rpc.start': [KEPT] })
|
||||
} finally {
|
||||
await host.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('still starts serving when showing live terminals fails at startup', async () => {
|
||||
await seedDuplicatePtyProfile()
|
||||
vi.spyOn(agentHookServer, 'transferPaneAuthority').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
state.placementRejects = true
|
||||
const steps: string[] = []
|
||||
state.onStartupStep = (step) => steps.push(step)
|
||||
|
||||
const { startOrcad } = await import('./orcad-entry')
|
||||
const host = await startOrcad({ noPairing: true, json: true })
|
||||
try {
|
||||
expect(steps).toEqual(['reconcile', 'place', 'rpc.start'])
|
||||
} finally {
|
||||
await host.stop()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { adoptWslHostLoginEnvironment } from './orcad-wsl-host-login-environment
|
||||
import { installOrcadProcessErrorGuards } from './orcad-process-error-guards'
|
||||
import { orcadWsPortOptions } from './orcad-ws-port-policy'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { placeUnshownLiveTerminalsAtStartup } from '../runtime/terminal/adoption/unshown-live-terminal-placement'
|
||||
|
||||
export { parseArgs }
|
||||
|
||||
@@ -303,6 +304,10 @@ async function startOrcadRuntime(
|
||||
|
||||
await runtime.refreshRestoredOrchestrationAuthority()
|
||||
await runtime.reconcileLegacyWorkerTerminals()
|
||||
// Switch point: main shows live terminals no pane shows, before any client attaches.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await placeUnshownLiveTerminalsAtStartup(runtime)
|
||||
}
|
||||
// Hook rows hydrated and the PTY runtime registered: stored sort tiers can be checked.
|
||||
runtime.agentTransitions.seedRestoredRows()
|
||||
void runtime.sidebarFacts?.reconcileSortStampTiers(getLocalPtyProvider())
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
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 { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import {
|
||||
placeUnshownLiveTerminals,
|
||||
type LiveTerminalPlacementPorts
|
||||
} from '../../runtime/terminal/adoption/unshown-live-terminal-placement'
|
||||
import { WindowSession } from './terminal-topology-window-session-fixture'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_GIT_WORKTREE_ID,
|
||||
openTopologyStore,
|
||||
reopenTopologyStore
|
||||
} from './terminal-topology-profile-fixture'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../ssh/config/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
const WORKTREE = FIXTURE_GIT_WORKTREE_ID
|
||||
const TAB = 'tab-1'
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
const directoryOf = new WeakMap<Store, string>()
|
||||
|
||||
async function openStore(seed: (window: WindowSession) => void): Promise<Store> {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-unshown-live-'))
|
||||
directories.push(directory)
|
||||
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
|
||||
const window = new WindowSession(getDefaultWorkspaceSession())
|
||||
seed(window)
|
||||
store.setWorkspaceSession(window.snapshot())
|
||||
return restart(store, directory)
|
||||
}
|
||||
|
||||
/** Quit and relaunch: what main loads is the profile on disk. */
|
||||
async function restart(store: Store, directory = directoryOf.get(store)!): Promise<Store> {
|
||||
const reopened = await reopenTopologyStore(store, directory)
|
||||
directoryOf.set(reopened, directory)
|
||||
return reopened
|
||||
}
|
||||
|
||||
type LivePty = Parameters<LiveTerminalPlacementPorts['isStillBinding']>[0]
|
||||
|
||||
function livePty(ptyId: string, recorded?: { tabId: string; paneKey: string }): LivePty {
|
||||
return {
|
||||
ptyId,
|
||||
incarnationId: `${ptyId}-inc`,
|
||||
worktreeId: WORKTREE,
|
||||
connectionId: null,
|
||||
runtimeSessionOwned: false,
|
||||
connected: true,
|
||||
tabId: recorded?.tabId ?? null,
|
||||
paneKey: recorded?.paneKey ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/** Main's startup pass over a real profile, the host census answering with `ptys`. */
|
||||
async function placeOn(
|
||||
store: Store,
|
||||
ptys: LivePty[],
|
||||
overrides: Partial<LiveTerminalPlacementPorts> = {}
|
||||
) {
|
||||
const recordedPanes = new Map<string, string>()
|
||||
const placement = await placeUnshownLiveTerminals({
|
||||
refreshInventory: async () => ({ workspaceIds: new Set([WORKTREE]) }),
|
||||
ptys: () => ptys,
|
||||
isStillBinding: () => false,
|
||||
hostIds: () => store.getWorkspaceSessionHostIds(),
|
||||
session: (hostId) => store.getWorkspaceSession(hostId),
|
||||
apply: (command) => store.applyLayoutCommand(command),
|
||||
recordPane: (ptyId, paneKey) => recordedPanes.set(ptyId, paneKey),
|
||||
...overrides
|
||||
})
|
||||
return { ...placement, recordedPanes }
|
||||
}
|
||||
|
||||
function terminalTabs(store: Store) {
|
||||
return store.getWorkspaceSession().tabsByWorktree[WORKTREE] ?? []
|
||||
}
|
||||
|
||||
describe('main shows a live terminal no pane shows, at startup with no window', () => {
|
||||
it('binds the pane its record names when that pane lost its binding: one tab, reattachable', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
|
||||
const placement = await placeOn(store, [
|
||||
livePty('pty-agent', { tabId: TAB, paneKey: `${TAB}:${LEAF}` })
|
||||
])
|
||||
|
||||
expect(placement.placed).toEqual(['pty-agent'])
|
||||
expect(placement.recordedPanes.get('pty-agent')).toBe(`${TAB}:${LEAF}`)
|
||||
expect(terminalTabs(store).map((tab) => tab.id)).toEqual([TAB])
|
||||
expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId).toEqual({
|
||||
[LEAF]: 'pty-agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a terminal no pane ever named in a new tab bound to it, kept across a restart', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
|
||||
const placement = await placeOn(store, [livePty('pty-cli')])
|
||||
const relaunched = await restart(store)
|
||||
|
||||
const tabs = terminalTabs(relaunched)
|
||||
expect(tabs).toHaveLength(2)
|
||||
const adopted = tabs.find((tab) => tab.id !== TAB)
|
||||
const bindings = relaunched.getWorkspaceSession().terminalLayoutsByTabId[adopted?.id ?? '']
|
||||
expect(Object.values(bindings?.ptyIdsByLeafId ?? {})).toEqual(['pty-cli'])
|
||||
// A census then names the new pane as the terminal's, so a window reads it as shown.
|
||||
expect(placement.recordedPanes.get('pty-cli')).toBe(
|
||||
`${adopted?.id}:${Object.keys(bindings?.ptyIdsByLeafId ?? {})[0]}`
|
||||
)
|
||||
// The next startup finds it shown and places nothing twice.
|
||||
expect((await placeOn(relaunched, [livePty('pty-cli')])).placed).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a terminal whose spawn is still binding its pane to that spawn', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
|
||||
const placement = await placeOn(store, [livePty('pty-starting')], {
|
||||
isStillBinding: () => true
|
||||
})
|
||||
|
||||
expect(placement).toMatchObject({ placed: [], declined: [] })
|
||||
expect(terminalTabs(store)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves alone a terminal a pane shows, one being killed, and a remote one', async () => {
|
||||
const store = await openStore((window) => {
|
||||
window.addTab(WORKTREE, TAB, LEAF)
|
||||
window.bind(TAB, LEAF, 'pty-bound')
|
||||
})
|
||||
const remote: LivePty = { ...livePty('ssh:host@@pty-remote'), connectionId: 'host' }
|
||||
|
||||
const placement = await placeOn(store, [livePty('pty-bound'), livePty('pty-closing'), remote], {
|
||||
refreshInventory: async () => ({
|
||||
exitingPtyIds: new Set(['pty-closing']),
|
||||
workspaceIds: new Set([WORKTREE])
|
||||
})
|
||||
})
|
||||
|
||||
expect(placement).toMatchObject({ placed: [], declined: [] })
|
||||
expect(terminalTabs(store).map((tab) => tab.id)).toEqual([TAB])
|
||||
})
|
||||
|
||||
it('places nothing in a workspace this host does not know (removed outside the app)', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
const stray: LivePty = { ...livePty('pty-stray'), worktreeId: 'repo-local::/fixture/removed' }
|
||||
|
||||
const placement = await placeOn(store, [stray])
|
||||
|
||||
expect(placement).toMatchObject({ placed: [], declined: [] })
|
||||
expect(Object.keys(store.getWorkspaceSession().tabsByWorktree)).toEqual([WORKTREE])
|
||||
})
|
||||
|
||||
it('a terminal main cannot place is declined, and the rest are still placed', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const apply: LiveTerminalPlacementPorts['apply'] = (command) =>
|
||||
command.adopt?.ptyId === 'pty-bad'
|
||||
? Promise.reject(new Error('record unreadable'))
|
||||
: store.applyLayoutCommand(command)
|
||||
|
||||
const placement = await placeOn(store, [livePty('pty-bad'), livePty('pty-good')], { apply })
|
||||
|
||||
expect(placement).toMatchObject({ placed: ['pty-good'], declined: ['pty-bad'] })
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0])).toContain('record unreadable')
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('places nothing, and does not fail, when the census itself fails', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const placement = await placeOn(store, [livePty('pty-cli')], {
|
||||
refreshInventory: () => Promise.reject(new Error('terminal service down'))
|
||||
})
|
||||
|
||||
expect(placement).toMatchObject({ placed: [], declined: [] })
|
||||
expect(terminalTabs(store)).toHaveLength(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('places nothing when the host census could not answer', async () => {
|
||||
const store = await openStore((window) => window.addTab(WORKTREE, TAB, LEAF))
|
||||
|
||||
const placement = await placeOn(store, [livePty('pty-cli')], {
|
||||
refreshInventory: async () => null
|
||||
})
|
||||
|
||||
expect(placement).toMatchObject({ placed: [], declined: [] })
|
||||
expect(terminalTabs(store)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import type { SshConnectionStatus } from '../../shared/ssh-types'
|
||||
import {
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
GIT_KEY,
|
||||
leaf,
|
||||
SSH_KEY
|
||||
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { deriveShutdownReconnectFacts } from '../runtime/terminal/recovery/shutdown-reconnect-facts'
|
||||
import { windowViewPatchForMain, windowViewSaveForMain } from './window-save-view'
|
||||
|
||||
const TARGET = 'target'
|
||||
const SSH_HOST = toSshExecutionHostId(TARGET)
|
||||
const RELAY = `ssh:${TARGET}@@pty-42`
|
||||
|
||||
function sshPartition(): WorkspaceSessionState {
|
||||
return addWorkspace(
|
||||
emptySession(),
|
||||
SSH_KEY,
|
||||
[{ id: 'group-s', tabs: [{ id: 'tab-s', leaves: [[leaf(3), RELAY]] }] }],
|
||||
SSH_HOST
|
||||
)
|
||||
}
|
||||
|
||||
function localPartition(): WorkspaceSessionState {
|
||||
return addWorkspace(emptySession(), GIT_KEY, [
|
||||
{
|
||||
id: 'group-g',
|
||||
tabs: [
|
||||
{ id: 'tab-live', leaves: [[leaf(1), 'pty-live']] },
|
||||
{ id: 'tab-gone', leaves: [[leaf(2), 'pty-gone']] }
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const factsFor =
|
||||
(
|
||||
live: string[],
|
||||
ssh: [string, SshConnectionStatus][],
|
||||
more: { exited?: string[]; others?: WorkspaceSessionState[] } = {}
|
||||
) =>
|
||||
(session: WorkspaceSessionState) =>
|
||||
deriveShutdownReconnectFacts(session, {
|
||||
isLiveLocalPty: (ptyId) => live.includes(ptyId),
|
||||
isKnownExited: (ptyId) => more.exited?.includes(ptyId) ?? false,
|
||||
sshStatusByTargetId: new Map(ssh),
|
||||
otherPartitions: () => more.others ?? []
|
||||
})
|
||||
|
||||
/** A window that never visited the SSH workspace and lost its reconnect bookkeeping. */
|
||||
function forgetfulSave(stored: WorkspaceSessionState): WorkspaceSessionState {
|
||||
return {
|
||||
...stored,
|
||||
activeWorktreeIdsOnShutdown: [],
|
||||
remoteSessionIdsByTabId: undefined,
|
||||
activeConnectionIdsAtShutdown: undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe('what the next startup reconnects comes from main, not the window’s save', () => {
|
||||
it('keeps an unvisited SSH tab’s relay session and its target the window dropped', () => {
|
||||
const stored = sshPartition()
|
||||
|
||||
const saved = windowViewSaveForMain(
|
||||
stored,
|
||||
forgetfulSave(stored),
|
||||
SSH_HOST,
|
||||
factsFor([], [[TARGET, 'reconnecting']])
|
||||
)
|
||||
|
||||
expect(saved.remoteSessionIdsByTabId).toEqual({ 'tab-s': RELAY })
|
||||
expect(saved.activeWorktreeIdsOnShutdown).toEqual([SSH_KEY])
|
||||
expect(saved.activeConnectionIdsAtShutdown).toEqual([TARGET])
|
||||
})
|
||||
|
||||
it('names only workspaces with a running local terminal, whatever the window sent', () => {
|
||||
const stored = localPartition()
|
||||
const save = { ...stored, activeWorktreeIdsOnShutdown: ['repo-1::/elsewhere'] }
|
||||
|
||||
const saved = windowViewSaveForMain(stored, save, null, factsFor(['pty-live'], []))
|
||||
|
||||
expect(saved.activeWorktreeIdsOnShutdown).toEqual([GIT_KEY])
|
||||
expect(saved).not.toHaveProperty('remoteSessionIdsByTabId')
|
||||
expect(saved).not.toHaveProperty('activeConnectionIdsAtShutdown')
|
||||
expect(
|
||||
windowViewSaveForMain(stored, save, null, factsFor([], [])).activeWorktreeIdsOnShutdown
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('a slept pane keeps its relay id as a wake hint only: the next startup does not reconnect it', () => {
|
||||
const stored = sshPartition()
|
||||
stored.sleepingAgentSessionsByPaneKey = {
|
||||
[`tab-s:${leaf(3)}`]: {
|
||||
paneKey: `tab-s:${leaf(3)}`,
|
||||
worktreeId: SSH_KEY,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
prompt: '',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'worktree-sleep'
|
||||
}
|
||||
}
|
||||
|
||||
const saved = windowViewSaveForMain(stored, stored, SSH_HOST, factsFor([], []))
|
||||
|
||||
expect(saved).not.toHaveProperty('remoteSessionIdsByTabId')
|
||||
expect(saved.activeWorktreeIdsOnShutdown).toEqual([])
|
||||
})
|
||||
|
||||
it('a target the user left offline is not redialed; a connected one is', () => {
|
||||
const stored = sshPartition()
|
||||
|
||||
expect(
|
||||
windowViewSaveForMain(stored, stored, SSH_HOST, factsFor([], [[TARGET, 'disconnected']]))
|
||||
).not.toHaveProperty('activeConnectionIdsAtShutdown')
|
||||
expect(
|
||||
windowViewSaveForMain(stored, stored, SSH_HOST, factsFor([], [['other', 'connected']]))
|
||||
.activeConnectionIdsAtShutdown
|
||||
).toEqual(['other'])
|
||||
})
|
||||
|
||||
it('the local save names a target in a passing state from a relay session in its SSH partition', () => {
|
||||
const local = localPartition()
|
||||
// The relay dropped mid-quit: the target reads reconnecting, not connected.
|
||||
const facts = factsFor([], [[TARGET, 'reconnecting']], { others: [sshPartition()] })
|
||||
|
||||
expect(windowViewSaveForMain(local, local, null, facts).activeConnectionIdsAtShutdown).toEqual([
|
||||
TARGET
|
||||
])
|
||||
})
|
||||
|
||||
it('a relay session proven exited (a slept plain shell) is not reconnected', () => {
|
||||
const stored = sshPartition()
|
||||
|
||||
const saved = windowViewSaveForMain(
|
||||
stored,
|
||||
stored,
|
||||
SSH_HOST,
|
||||
factsFor([], [[TARGET, 'reconnecting']], { exited: [RELAY] })
|
||||
)
|
||||
|
||||
expect(saved).not.toHaveProperty('remoteSessionIdsByTabId')
|
||||
expect(saved.activeWorktreeIdsOnShutdown).toEqual([])
|
||||
expect(saved).not.toHaveProperty('activeConnectionIdsAtShutdown')
|
||||
})
|
||||
|
||||
it('a patch has main’s facts only for the fields it carries', () => {
|
||||
const stored = sshPartition()
|
||||
const facts = factsFor([], [[TARGET, 'connected']])
|
||||
|
||||
expect(
|
||||
windowViewPatchForMain(stored, { activeConnectionIdsAtShutdown: [] }, SSH_HOST, facts)
|
||||
).toEqual({ activeConnectionIdsAtShutdown: [TARGET] })
|
||||
expect(windowViewPatchForMain(stored, { activeRepoId: 'r' }, SSH_HOST, facts)).toEqual({
|
||||
activeRepoId: 'r'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-la
|
||||
import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model'
|
||||
import { patchStoredSession } from '../../shared/workspace-layout/workspace-layout-record-patch'
|
||||
import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save'
|
||||
import type { ShutdownReconnectFacts } from '../runtime/terminal/recovery/shutdown-reconnect-facts'
|
||||
|
||||
const FIELD_OWNERS: Readonly<Record<string, string>> = SESSION_FIELD_OWNERS
|
||||
|
||||
@@ -109,7 +110,8 @@ function withMainLayoutFields<T extends WorkspaceSessionPatch>(
|
||||
export function windowViewSaveForMain(
|
||||
stored: WorkspaceSessionState,
|
||||
save: WorkspaceSessionState,
|
||||
hostId: string | null | undefined
|
||||
hostId: string | null | undefined,
|
||||
reconnectFacts?: ReconnectFactsFor
|
||||
): WorkspaceSessionState {
|
||||
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
|
||||
if (parseExecutionHostId(partition)?.kind === 'runtime') {
|
||||
@@ -117,7 +119,39 @@ export function windowViewSaveForMain(
|
||||
}
|
||||
const joined = storedWithWindowView(partition, stored, save)
|
||||
const fields = [...new Set([...Object.keys(save), ...Object.keys(joined)])]
|
||||
return withMainLayoutFields(save, joined, fields.filter(isLayoutField))
|
||||
const result = withMainLayoutFields(save, joined, fields.filter(isLayoutField))
|
||||
// Main's own reconnect facts replace the window's (C1a; B2 folds this into its view merge).
|
||||
return reconnectFacts
|
||||
? withReconnectFacts(result, reconnectFacts(result, partition), RECONNECT_FACT_FIELDS)
|
||||
: result
|
||||
}
|
||||
|
||||
/** Main's reconnect facts for the session it is about to store. */
|
||||
export type ReconnectFactsFor = (
|
||||
session: WorkspaceSessionState,
|
||||
partition: ExecutionHostId
|
||||
) => ShutdownReconnectFacts
|
||||
|
||||
const RECONNECT_FACT_FIELDS = [
|
||||
'activeWorktreeIdsOnShutdown',
|
||||
'remoteSessionIdsByTabId',
|
||||
'activeConnectionIdsAtShutdown'
|
||||
] as const satisfies readonly (keyof ShutdownReconnectFacts)[]
|
||||
|
||||
function withReconnectFacts<T extends WorkspaceSessionPatch>(
|
||||
session: T,
|
||||
facts: ShutdownReconnectFacts,
|
||||
fields: readonly (keyof ShutdownReconnectFacts)[]
|
||||
): T {
|
||||
const next: T = { ...session }
|
||||
for (const field of fields) {
|
||||
if (facts[field] === undefined) {
|
||||
delete next[field]
|
||||
} else {
|
||||
Object.assign(next, { [field]: facts[field] })
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,13 +162,20 @@ export function windowViewSaveForMain(
|
||||
export function windowViewPatchForMain(
|
||||
stored: WorkspaceSessionState,
|
||||
patch: WorkspaceSessionPatch,
|
||||
hostId: string | null | undefined
|
||||
hostId: string | null | undefined,
|
||||
reconnectFacts?: ReconnectFactsFor
|
||||
): WorkspaceSessionPatch {
|
||||
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
|
||||
const fields = Object.keys(patch).filter(isLayoutField)
|
||||
if (parseExecutionHostId(partition)?.kind === 'runtime' || fields.length === 0) {
|
||||
if (parseExecutionHostId(partition)?.kind === 'runtime') {
|
||||
return patch
|
||||
}
|
||||
const joined = storedWithWindowView(partition, stored, { ...stored, ...patch })
|
||||
return withMainLayoutFields(patch, joined, fields)
|
||||
const fields = Object.keys(patch).filter(isLayoutField)
|
||||
const joined =
|
||||
fields.length === 0 ? null : storedWithWindowView(partition, stored, { ...stored, ...patch })
|
||||
const result = joined ? withMainLayoutFields(patch, joined, fields) : patch
|
||||
// Only the reconnect facts the patch itself carries are replaced with main's.
|
||||
const carried = RECONNECT_FACT_FIELDS.filter((field) => Object.hasOwn(patch, field))
|
||||
return reconnectFacts && carried.length > 0
|
||||
? withReconnectFacts(result, reconnectFacts({ ...stored, ...result }, partition), carried)
|
||||
: result
|
||||
}
|
||||
|
||||
@@ -109,6 +109,22 @@ describe('layout.apply', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('takes no live-terminal adoption from the window: only main places a running terminal', () => {
|
||||
const apply = eraseRpcMethods(LAYOUT_APPLY_METHODS).find(
|
||||
(method) => method.name === 'layout.apply'
|
||||
)
|
||||
const create = {
|
||||
type: 'createTerminalTab',
|
||||
workspace: WORKTREE,
|
||||
tabId: 'tab-2',
|
||||
leafId: '44444444-4444-4444-8444-444444444444'
|
||||
} as const
|
||||
|
||||
expect(apply?.params?.parse({ command: { ...create, adopt: { ptyId: 'pty-1' } } })).toEqual({
|
||||
command: create
|
||||
})
|
||||
})
|
||||
|
||||
it('takes no close semantics or blank title from the window', async () => {
|
||||
const apply = eraseRpcMethods(LAYOUT_APPLY_METHODS).find(
|
||||
(method) => method.name === 'layout.apply'
|
||||
|
||||
@@ -208,4 +208,6 @@ export type PtyControllerInventory = Readonly<{
|
||||
allLivePtyIds: ReadonlySet<string>
|
||||
terminalIdentityByPtyId: ReadonlyMap<string, PtyControllerTerminalIdentity>
|
||||
queriedHostIds: ReadonlySet<ExecutionHostId>
|
||||
/** Live PTYs the host is killing: still live work, but no longer any pane's to show. */
|
||||
exitingPtyIds?: ReadonlySet<string>
|
||||
}>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Layout-owner build: main shows every live local terminal that no pane shows, in the pane its
|
||||
// record names when main still holds that pane free, else in a new tab bound to it. It runs at
|
||||
// each startup and window load, with or without a window, so no client has to find them.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type {
|
||||
CommandOf,
|
||||
LayoutApplyReply
|
||||
} from '../../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
|
||||
|
||||
type PlacementPty = Pick<
|
||||
RuntimePtyWorktreeRecord,
|
||||
'ptyId' | 'incarnationId' | 'worktreeId' | 'connectionId' | 'runtimeSessionOwned' | 'tabId'
|
||||
> & { paneKey: string | null; connected: boolean }
|
||||
|
||||
export type LiveTerminalPlacementPorts = {
|
||||
/** A fresh census of every local workspace; null when the host could not answer. */
|
||||
refreshInventory: () => Promise<{
|
||||
exitingPtyIds?: ReadonlySet<string>
|
||||
/** The workspaces this host knows: a terminal filed under any other id has no tab to join. */
|
||||
workspaceIds: ReadonlySet<string>
|
||||
} | null>
|
||||
ptys: () => Iterable<PlacementPty>
|
||||
/** A spawn still binding its pane (its workspace's spawn lock is held): not proven to have none. */
|
||||
isStillBinding: (pty: PlacementPty) => boolean
|
||||
hostIds: () => readonly ExecutionHostId[]
|
||||
session: (hostId: ExecutionHostId) => WorkspaceSessionState | null | undefined
|
||||
apply: (command: CommandOf<'createTerminalTab'> & { tabId: string }) => Promise<LayoutApplyReply>
|
||||
/** Records the pane main showed the terminal in, so a census names it as the terminal's pane. */
|
||||
recordPane: (ptyId: string, paneKey: string) => void
|
||||
}
|
||||
|
||||
export type LiveTerminalPlacement = { placed: string[]; declined: string[] }
|
||||
|
||||
/** Every terminal a pane of a partition main holds names, a tab-level binding included. */
|
||||
function paneBoundPtyIds(ports: LiveTerminalPlacementPorts): Set<string> {
|
||||
const bound = new Set<string>()
|
||||
for (const hostId of ports.hostIds()) {
|
||||
const session = ports.session(hostId)
|
||||
for (const layout of Object.values(session?.terminalLayoutsByTabId ?? {})) {
|
||||
for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) {
|
||||
if (ptyId) {
|
||||
bound.add(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tabs of Object.values(session?.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.ptyId) {
|
||||
bound.add(tab.ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
function recordedPaneKeyOf(pty: PlacementPty): string | undefined {
|
||||
const pane = parsePaneKey(pty.paneKey ?? '')
|
||||
return pane && pane.tabId === pty.tabId && pty.paneKey ? pty.paneKey : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a terminal of this machine the host census saw running, not being killed, and named by no
|
||||
* pane: an SSH or remote server's terminal is unverifiable here, and a killed one was closed.
|
||||
*/
|
||||
function isUnshown(
|
||||
pty: PlacementPty,
|
||||
bound: ReadonlySet<string>,
|
||||
inventory: { exitingPtyIds?: ReadonlySet<string>; workspaceIds: ReadonlySet<string> }
|
||||
): boolean {
|
||||
return (
|
||||
pty.connected &&
|
||||
inventory.workspaceIds.has(pty.worktreeId) &&
|
||||
pty.connectionId === null &&
|
||||
!pty.runtimeSessionOwned &&
|
||||
pty.worktreeId !== FLOATING_TERMINAL_WORKTREE_ID &&
|
||||
!bound.has(pty.ptyId) &&
|
||||
!inventory.exitingPtyIds?.has(pty.ptyId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort per terminal: a census that fails places nothing, and a terminal main cannot place
|
||||
* is declined, so one bad record never stops the rest (or a server's startup).
|
||||
*/
|
||||
export async function placeUnshownLiveTerminals(
|
||||
ports: LiveTerminalPlacementPorts
|
||||
): Promise<LiveTerminalPlacement> {
|
||||
const placement: LiveTerminalPlacement = { placed: [], declined: [] }
|
||||
const failures: string[] = []
|
||||
const inventory = await ports.refreshInventory().catch((error: unknown) => {
|
||||
failures.push(`census: ${reasonOf(error)}`)
|
||||
return null
|
||||
})
|
||||
// An unanswered census proves nothing unshown.
|
||||
if (inventory) {
|
||||
// A snapshot: the census and the spawns that run during the awaits below change the records.
|
||||
for (const pty of Array.from(ports.ptys())) {
|
||||
try {
|
||||
await placeOne(ports, pty, inventory, placement)
|
||||
} catch (error) {
|
||||
placement.declined.push(pty.ptyId)
|
||||
failures.push(`${pty.ptyId}: ${reasonOf(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (placement.declined.length > 0 || failures.length > 0) {
|
||||
// A live agent the user can no longer see has to be diagnosable from the log.
|
||||
console.warn('[layout] live terminals left without a pane', placement.declined, failures)
|
||||
}
|
||||
return placement
|
||||
}
|
||||
|
||||
async function placeOne(
|
||||
ports: LiveTerminalPlacementPorts,
|
||||
pty: PlacementPty,
|
||||
inventory: Parameters<typeof isUnshown>[2],
|
||||
placement: LiveTerminalPlacement
|
||||
): Promise<void> {
|
||||
// Re-read per terminal: an earlier placement bound panes, and a spawn may have bound since.
|
||||
if (!isUnshown(pty, paneBoundPtyIds(ports), inventory) || ports.isStillBinding(pty)) {
|
||||
return
|
||||
}
|
||||
const recordedPaneKey = recordedPaneKeyOf(pty)
|
||||
const reply = await ports.apply({
|
||||
type: 'createTerminalTab',
|
||||
workspace: pty.worktreeId,
|
||||
tabId: randomUUID(),
|
||||
leafId: randomUUID(),
|
||||
adopt: {
|
||||
ptyId: pty.ptyId,
|
||||
...(pty.incarnationId ? { incarnationId: pty.incarnationId } : {}),
|
||||
...(recordedPaneKey ? { recordedPaneKey } : {})
|
||||
}
|
||||
})
|
||||
if (reply.ok) {
|
||||
if (reply.result.paneKey) {
|
||||
ports.recordPane(pty.ptyId, reply.result.paneKey)
|
||||
}
|
||||
placement.placed.push(pty.ptyId)
|
||||
} else if (reply.code !== 'host_not_owned') {
|
||||
placement.declined.push(pty.ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
function reasonOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** For startup callers: the pass never stops a server from coming up, whatever fails in it. */
|
||||
export async function placeUnshownLiveTerminalsAtStartup(
|
||||
runtime: { placeUnshownLiveTerminals: () => Promise<unknown> } | null | undefined
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runtime?.placeUnshownLiveTerminals()
|
||||
} catch (error) {
|
||||
console.warn('[layout] showing live terminals at startup failed:', reasonOf(error))
|
||||
}
|
||||
}
|
||||
|
||||
/** The live terminals a census reports its host is killing. */
|
||||
export function exitingPtyIdsOf(sessions: readonly { id: string; exiting?: true }[]): Set<string> {
|
||||
return new Set(sessions.flatMap((session) => (session.exiting === true ? [session.id] : [])))
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import { NO_OBSERVING_PROVIDER_REASON } from '../../../../shared/pty-liveness-ve
|
||||
import { buildControllerTerminalIdentities } from '../../orca-runtime-build-controller-terminal-identities'
|
||||
import { retireOrchestrationAuthorityAbsentFromInventory } from '../../runtime-restored-orchestration-authority-sweep'
|
||||
import { worktreeIdsEqual } from '../../../../shared/worktree/id'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { exitingPtyIdsOf } from '../adoption/unshown-live-terminal-placement'
|
||||
|
||||
export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory extends OrcaRuntimeWithRefreshPtyWorktreeRecordsFromController {
|
||||
protected async refreshPtyWorktreeRecordsWithControllerInventory(
|
||||
@@ -301,7 +303,9 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve
|
||||
livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds,
|
||||
allLivePtyIds,
|
||||
terminalIdentityByPtyId: controllerIdentityByPtyId,
|
||||
queriedHostIds
|
||||
queriedHostIds,
|
||||
// Switch point: main places live terminals itself and must skip ones being killed.
|
||||
...(ORCA_LAYOUT_OWNER_IS_MAIN ? { exitingPtyIds: exitingPtyIdsOf(sessions) } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Layout-owner build: what the next startup reconnects, derived by main from its own layout, its
|
||||
// terminal records and its SSH connections, never taken from a window's save. The rules are the
|
||||
// window's (buildTerminalSessionData, buildActiveConnectionIdsAtShutdown), read from main's state.
|
||||
|
||||
import { isOwnedSshTargetId } from '../../../../shared/execution-host'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
|
||||
export type ShutdownReconnectFacts = Pick<
|
||||
WorkspaceSessionState,
|
||||
'activeWorktreeIdsOnShutdown' | 'remoteSessionIdsByTabId' | 'activeConnectionIdsAtShutdown'
|
||||
>
|
||||
|
||||
export type ReconnectFactSources = {
|
||||
/** A terminal of this machine main's records show running. */
|
||||
isLiveLocalPty: (ptyId: string) => boolean
|
||||
/** Proven exited (a recorded exit); a lost relay is unverifiable, not exited. */
|
||||
isKnownExited: (ptyId: string) => boolean
|
||||
sshStatusByTargetId: ReadonlyMap<string, SshConnectionStatus>
|
||||
/** The other partitions' sessions: the connection list is global across them. */
|
||||
otherPartitions?: () => readonly WorkspaceSessionState[]
|
||||
}
|
||||
|
||||
/** Every terminal a tab's panes name, in tree order, then its tab-level id. */
|
||||
function tabPtyIds(session: WorkspaceSessionState, tabId: string, tabPtyId: string | null) {
|
||||
const layout = session.terminalLayoutsByTabId[tabId]
|
||||
const byLeaf = layout?.ptyIdsByLeafId ?? {}
|
||||
const leafIds = collectLayoutLeafIdsInOrder(layout?.root ?? null)
|
||||
const sleeping = session.sleepingAgentSessionsByPaneKey ?? {}
|
||||
const panes = leafIds.flatMap((leafId) => {
|
||||
const ptyId = byLeaf[leafId]
|
||||
return ptyId ? [{ ptyId, sleeping: Boolean(sleeping[`${tabId}:${leafId}`]) }] : []
|
||||
})
|
||||
// A tab-level id is its single pane's: slept when that pane is.
|
||||
if (tabPtyId && !panes.some((pane) => pane.ptyId === tabPtyId)) {
|
||||
const solePaneSleeping = leafIds.length === 1 && Boolean(sleeping[`${tabId}:${leafIds[0]}`])
|
||||
panes.push({ ptyId: tabPtyId, sleeping: solePaneSleeping })
|
||||
}
|
||||
return panes
|
||||
}
|
||||
|
||||
/** Tab id → the relay session it reconnects: an awake pane's, not proven exited. */
|
||||
function relaySessionIdsByTabId(
|
||||
session: WorkspaceSessionState,
|
||||
sources: ReconnectFactSources
|
||||
): Record<string, string> {
|
||||
const relays: Record<string, string> = {}
|
||||
for (const tabs of Object.values(session.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
// A slept pane keeps its relay id as a wake hint; only an awake one reconnects.
|
||||
const relay = tabPtyIds(session, tab.id, tab.ptyId).find(
|
||||
(pane) =>
|
||||
!pane.sleeping && parseAppSshPtyId(pane.ptyId) && !sources.isKnownExited(pane.ptyId)
|
||||
)
|
||||
if (relay) {
|
||||
relays[tab.id] = relay.ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
return relays
|
||||
}
|
||||
|
||||
export function deriveShutdownReconnectFacts(
|
||||
session: WorkspaceSessionState,
|
||||
sources: ReconnectFactSources
|
||||
): ShutdownReconnectFacts {
|
||||
const relays = relaySessionIdsByTabId(session, sources)
|
||||
const activeWorktreeIds = Object.entries(session.tabsByWorktree)
|
||||
.filter(([, tabs]) =>
|
||||
tabs.some(
|
||||
(tab) =>
|
||||
Boolean(relays[tab.id]) ||
|
||||
tabPtyIds(session, tab.id, tab.ptyId).some((pane) => sources.isLiveLocalPty(pane.ptyId))
|
||||
)
|
||||
)
|
||||
.map(([worktreeId]) => worktreeId)
|
||||
const everyRelay = [
|
||||
...Object.values(relays),
|
||||
...(sources.otherPartitions?.() ?? []).flatMap((other) =>
|
||||
Object.values(relaySessionIdsByTabId(other, sources))
|
||||
)
|
||||
]
|
||||
return {
|
||||
activeWorktreeIdsOnShutdown: activeWorktreeIds,
|
||||
remoteSessionIdsByTabId: Object.keys(relays).length > 0 ? relays : undefined,
|
||||
activeConnectionIdsAtShutdown: activeConnectionIds(sources, everyRelay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected targets, and the targets a surviving relay session names unless the user left them
|
||||
* offline. A runtime-owned target belongs to the runtime layer and is never redialed.
|
||||
*/
|
||||
function activeConnectionIds(
|
||||
sources: ReconnectFactSources,
|
||||
relaySessionIds: readonly string[]
|
||||
): string[] | undefined {
|
||||
const targetIds = new Set<string>()
|
||||
for (const [targetId, status] of sources.sshStatusByTargetId) {
|
||||
if (status === 'connected' && !isOwnedSshTargetId(targetId)) {
|
||||
targetIds.add(targetId)
|
||||
}
|
||||
}
|
||||
for (const sessionId of relaySessionIds) {
|
||||
const targetId = parseAppSshPtyId(sessionId)?.connectionId
|
||||
const status = targetId ? sources.sshStatusByTargetId.get(targetId) : undefined
|
||||
if (
|
||||
targetId &&
|
||||
!isOwnedSshTargetId(targetId) &&
|
||||
status &&
|
||||
status !== 'disconnected' &&
|
||||
status !== 'auth-failed'
|
||||
) {
|
||||
targetIds.add(targetId)
|
||||
}
|
||||
}
|
||||
return targetIds.size > 0 ? [...targetIds] : undefined
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
|
||||
import { OrcaRuntimeWithAutomationOperations } from './automation-operations'
|
||||
import { OrcaRuntimeWithRestoredTerminalPlacement } from './restored-terminal-placement'
|
||||
import type {
|
||||
RuntimeTerminalOrphanAdoptionRequest,
|
||||
RuntimeTerminalOrphanAdoptionResult
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
} from '../../../../shared/artifacts'
|
||||
import { worktreeIdsEqual } from '../../../../shared/worktree/id'
|
||||
|
||||
export abstract class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations {
|
||||
export abstract class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithRestoredTerminalPlacement {
|
||||
protected abstract getOwnWorkspaceSessionForWorktree(
|
||||
worktreeId: string
|
||||
): WorkspaceSessionState | null
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
|
||||
const FOLDER: FolderWorkspace = {
|
||||
id: 'notes-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Notes',
|
||||
folderPath: '/fixture/notes',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const FOLDER_KEY = folderWorkspaceKey(FOLDER.id)
|
||||
const LIVE_PTY = (worktreeId: string) => ({
|
||||
id: 'pty-agent',
|
||||
cwd: FOLDER.folderPath,
|
||||
title: 'claude',
|
||||
worktreeId,
|
||||
incarnationId: 'inc-1'
|
||||
})
|
||||
|
||||
function runtimeWithLiveTerminal(worktreeId: string) {
|
||||
const applyLayoutCommand = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
result: { tabId: 'tab-new', leafId: 'leaf-new', paneKey: 'tab-new:leaf-new' }
|
||||
}))
|
||||
const store = {
|
||||
getRepos: () => [],
|
||||
getProjectGroups: () => [{ id: 'group-1', parentPath: '/fixture' }],
|
||||
getFolderWorkspaces: () => [FOLDER],
|
||||
getSettings: () => ({}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorkspaceSessionHostIds: () => ['local'],
|
||||
getWorkspaceSession: () => getDefaultWorkspaceSession(),
|
||||
applyLayoutCommand
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup pass reads only these store methods.
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [LIVE_PTY(worktreeId)],
|
||||
listProcessesWithHostScope: async () => ({
|
||||
processes: [LIVE_PTY(worktreeId)],
|
||||
hostIds: ['local' as const]
|
||||
})
|
||||
})
|
||||
return { runtime, applyLayoutCommand }
|
||||
}
|
||||
|
||||
describe('main’s startup pass over the host census, layout-owner build', () => {
|
||||
it('shows a live terminal of a project-group folder workspace in a tab bound to it', async () => {
|
||||
const { runtime, applyLayoutCommand } = runtimeWithLiveTerminal(FOLDER_KEY)
|
||||
|
||||
const placement = await runtime.placeUnshownLiveTerminals()
|
||||
|
||||
expect(placement).toEqual({ placed: ['pty-agent'], declined: [] })
|
||||
expect(applyLayoutCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'createTerminalTab',
|
||||
workspace: FOLDER_KEY,
|
||||
adopt: { ptyId: 'pty-agent', incarnationId: 'inc-1' }
|
||||
}),
|
||||
['local']
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a live terminal of a workspace this host does not know without a tab', async () => {
|
||||
const { runtime, applyLayoutCommand } = runtimeWithLiveTerminal('repo-gone::/fixture/gone')
|
||||
|
||||
expect(await runtime.placeUnshownLiveTerminals()).toEqual({ placed: [], declined: [] })
|
||||
expect(applyLayoutCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Layout-owner build: what main itself restores at startup and window load, with or without a
|
||||
// window: the live terminals no pane shows, and what the next startup reconnects.
|
||||
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { worktreeIdentityKey } from '../../../../shared/worktree/id'
|
||||
import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import { getSshConnectionManager } from '../../../ssh/targets/ssh-target-registry'
|
||||
import {
|
||||
recordPtySurface,
|
||||
SURFACE_CLAIM_WITHOUT_STANDING
|
||||
} from '../../pty-recorded-surface-topology'
|
||||
import type { WindowLayoutCommand } from '../../runtime-layout-apply'
|
||||
import {
|
||||
placeUnshownLiveTerminals,
|
||||
type LiveTerminalPlacement
|
||||
} from '../../terminal/adoption/unshown-live-terminal-placement'
|
||||
import {
|
||||
deriveShutdownReconnectFacts,
|
||||
type ShutdownReconnectFacts
|
||||
} from '../../terminal/recovery/shutdown-reconnect-facts'
|
||||
import { OrcaRuntimeWithAutomationOperations } from './automation-operations'
|
||||
|
||||
export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRuntimeWithAutomationOperations {
|
||||
abstract applyLayoutCommand(command: WindowLayoutCommand): Promise<LayoutApplyReply>
|
||||
|
||||
/** Layout-owner build: shows every live local terminal no pane shows (startup, window load). */
|
||||
async placeUnshownLiveTerminals(): Promise<LiveTerminalPlacement> {
|
||||
// A host that keeps no local terminals across restarts has none to find.
|
||||
if (!this.canRecoverPersistentLocalPtysFn()) {
|
||||
return { placed: [], declined: [] }
|
||||
}
|
||||
return placeUnshownLiveTerminals({
|
||||
refreshInventory: async () => {
|
||||
// Project-group folder workspaces are not in the worktree map; they resolve on their own.
|
||||
const workspaces = [
|
||||
...(await this.getResolvedWorktreeMap()).values(),
|
||||
...(this.store?.getFolderWorkspaces?.() ?? []).map((folder) =>
|
||||
this.folderWorkspaceToResolvedWorktree(folder)
|
||||
)
|
||||
]
|
||||
const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory(
|
||||
workspaces,
|
||||
null,
|
||||
undefined,
|
||||
null
|
||||
)
|
||||
const workspaceIds = new Set(workspaces.map((workspace) => workspace.id))
|
||||
return inventory && { ...inventory, workspaceIds }
|
||||
},
|
||||
ptys: () => this.ptysById.values(),
|
||||
// A window spawn holds its workspace's lock until its pane is recorded (spawn-run.ts).
|
||||
isStillBinding: (pty) =>
|
||||
this.terminalMutationLock.hasActiveSpawns(worktreeIdentityKey(pty.worktreeId)),
|
||||
hostIds: () => this.store?.getWorkspaceSessionHostIds?.() ?? [],
|
||||
session: (hostId) => this.store?.getWorkspaceSession?.(hostId),
|
||||
apply: (command) => this.applyLayoutCommand(command),
|
||||
recordPane: (ptyId, paneKey) => {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const pane = parsePaneKey(paneKey)
|
||||
if (pty && pane) {
|
||||
// No graph standing: a window's graph that later shows otherwise still wins.
|
||||
recordPtySurface(pty, pane.tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Layout-owner build: what the next startup reconnects for `session`, from main's own state. */
|
||||
shutdownReconnectFactsFor(
|
||||
session: WorkspaceSessionState,
|
||||
partition: ExecutionHostId
|
||||
): ShutdownReconnectFacts {
|
||||
const store = this.store
|
||||
return deriveShutdownReconnectFacts(session, {
|
||||
isLiveLocalPty: (ptyId) => {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
return pty?.connected === true && pty.connectionId === null
|
||||
},
|
||||
isKnownExited: (ptyId) => this.isPtyKnownExited(ptyId),
|
||||
sshStatusByTargetId: new Map(
|
||||
[...(getSshConnectionManager()?.getAllStates() ?? [])].map(([id, state]) => [
|
||||
id,
|
||||
state.status
|
||||
])
|
||||
),
|
||||
// The connection list is global: relay sessions in every partition name its targets.
|
||||
otherPartitions: () =>
|
||||
(store?.getWorkspaceSessionHostIds?.() ?? []).flatMap((hostId) => {
|
||||
const other = hostId === partition ? null : store?.getWorkspaceSession?.(hostId)
|
||||
return other ? [other] : []
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { placeUnshownLiveTerminalsAtStartup } from '../runtime/terminal/adoption/unshown-live-terminal-placement'
|
||||
import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery'
|
||||
import { logStartupMilestone } from './startup-diagnostics'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
@@ -52,8 +54,16 @@ export function registerMainProcessIpcHandlers(): void {
|
||||
managedWslCliStartupBarrierReady: state.managedWslCliStartupBarrierReady,
|
||||
localPtyProviderStartupReady: state.localPtyProviderStartupReady,
|
||||
reconcile: async () => {
|
||||
await state.runtime?.refreshRestoredOrchestrationAuthority()
|
||||
return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
||||
try {
|
||||
await state.runtime?.refreshRestoredOrchestrationAuthority()
|
||||
return await state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
||||
} finally {
|
||||
// Switch point: main shows live terminals no pane shows, after workers take their panes,
|
||||
// even when that recovery failed: the window no longer adopts them itself.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await placeUnshownLiveTerminalsAtStartup(state.runtime)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDeferredRecoveryError: (error) => {
|
||||
console.warn('[orchestration] legacy worker provider-ready recovery failed', error)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { app, type BrowserWindow } from 'electron'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { placeUnshownLiveTerminalsAtStartup } from '../runtime/terminal/adoption/unshown-live-terminal-placement'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import {
|
||||
getCanonicalUserDataPath,
|
||||
@@ -156,6 +158,10 @@ async function launchServeMode(
|
||||
)
|
||||
await runtime.refreshRestoredOrchestrationAuthority()
|
||||
await runtime.reconcileLegacyWorkerTerminals()
|
||||
// Switch point: main shows live terminals no pane shows, before any client attaches.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
await placeUnshownLiveTerminalsAtStartup(runtime)
|
||||
}
|
||||
// Why: headless servers can't mount <webview> panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest.
|
||||
if (state.headlessBrowserDisplayAvailable) {
|
||||
runtime.setOffscreenBrowserBackend(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// A CLI-started terminal the window never mounted reads as unowned to the host's census. With the
|
||||
// layout-owner switch on, every path that finds it shows exactly main's one tab for it.
|
||||
// layout-owner switch on, main shows it (placeUnshownLiveTerminals) and names its pane; every path
|
||||
// that finds it shows exactly main's one tab for it, and the window never asks main to adopt it.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
@@ -93,26 +94,30 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
let sync: WorkspaceLayoutFrameSync | null = null
|
||||
let model: WorkspaceLayoutModel
|
||||
let streamUp: boolean
|
||||
/** Every layout command the window sent main. */
|
||||
let applied: LayoutCommand[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
sync?.close()
|
||||
sync = null
|
||||
streamUp = true
|
||||
model = loadWorkspaceLayoutWithoutReport('local', cliSession(), nameBasedLoadContext()).layout
|
||||
applied = []
|
||||
main.apply = (command) => {
|
||||
const applied = applyLayoutCommand(model, command, {
|
||||
applied.push(command)
|
||||
const result = applyLayoutCommand(model, command, {
|
||||
mintId: () => 'minted-tab',
|
||||
mintLeafId: () => leaf(9),
|
||||
now: () => 1
|
||||
})
|
||||
if (!applied.ok) {
|
||||
return { ok: false, code: applied.code }
|
||||
if (!result.ok) {
|
||||
return { ok: false, code: result.code }
|
||||
}
|
||||
model = applied.model
|
||||
model = result.model
|
||||
if (streamUp) {
|
||||
stream.onFrame!(frameOf(model))
|
||||
}
|
||||
return { ok: true, result: applied.result }
|
||||
return { ok: true, result: result.result }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -129,7 +134,7 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
(store.getState().tabsByWorktree[GIT_KEY] ?? []).map((tab) => tab.id)
|
||||
const mainTabIds = () => (model.workspaces[GIT_KEY]?.tabs ?? []).map((tab) => tab.id)
|
||||
|
||||
it('the stream is closed during the visit: main names its tab, and the reopened stream draws it', async () => {
|
||||
it('the stream is closed during the visit: the window draws nothing, and the reopened stream draws main’s tab', async () => {
|
||||
const store = await bootWindow()
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
@@ -140,11 +145,18 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
expect(terminalTabIds(store)).toEqual([])
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
await adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [CLI_PTY], async () => unowned)
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[CLI_PTY],
|
||||
async () => unowned
|
||||
)
|
||||
await flushMicrotasks()
|
||||
|
||||
// A closed stream cannot say main still holds it: declined, not drawn by the window.
|
||||
expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [CLI_PTY] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(mainTabIds()).toEqual(['tab-cli'])
|
||||
expect(applied).toEqual([])
|
||||
stream.onStatus!({ state: 'connecting' })
|
||||
streamUp = true
|
||||
stream.onFrame!(frameOf(model))
|
||||
@@ -152,7 +164,7 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('main’s frame arrives after the census: main names its tab, and that frame draws it', async () => {
|
||||
it('main’s binding frame arrives after the census: the window draws nothing, and that frame draws main’s tab', async () => {
|
||||
const store = await bootWindow()
|
||||
const before = loadWorkspaceLayoutWithoutReport('local', emptySession(), nameBasedLoadContext())
|
||||
stream.onFrame!(frameOf(before.layout))
|
||||
@@ -166,11 +178,17 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
streamUp = false
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
await adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [CLI_PTY], async () => unowned)
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[CLI_PTY],
|
||||
async () => unowned
|
||||
)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [CLI_PTY] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(mainTabIds()).toEqual(['tab-cli'])
|
||||
expect(applied).toEqual([])
|
||||
streamUp = true
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
@@ -197,17 +215,18 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
|
||||
expect(outcome).toBe('adopted')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(applied).toEqual([])
|
||||
expect(mainTabIds()).toEqual(['tab-cli'])
|
||||
store.getState().ensureWorktreeRootGroup(GIT_KEY)
|
||||
await flushMicrotasks()
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('main refusing the adoption (a WSL move) leaves the terminal declined, not shown, and makes no tab', async () => {
|
||||
it('declines a terminal main holds in no pane, rather than making a tab or asking main', async () => {
|
||||
const store = await bootWindow()
|
||||
model = loadWorkspaceLayoutWithoutReport('local', emptySession(), nameBasedLoadContext()).layout
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
main.apply = () => ({ ok: false, code: 'wsl_move_in_progress' })
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
@@ -219,5 +238,6 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
|
||||
expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [CLI_PTY] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(applied).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,11 @@ import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { worktreeIdsEqual } from '../../../shared/worktree/id'
|
||||
import type { useAppStore } from '@/store'
|
||||
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
|
||||
import { adoptLiveTerminalThroughLayoutOwner } from '@/store/terminals/terminal-tab-create-through-layout-owner'
|
||||
import { layoutFramesHoldTerminalPane } from '@/runtime/layout-frame-terminal-panes'
|
||||
import {
|
||||
layoutFramesHoldTerminalPane,
|
||||
layoutFrameTerminalTab
|
||||
} from '@/runtime/layout-frame-terminal-panes'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
|
||||
import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id'
|
||||
import type {
|
||||
LiveTerminalSurfaceOwner,
|
||||
@@ -113,11 +116,7 @@ function adoptHostOwnedSurface(
|
||||
// Switch point: main's frames draw a pane main holds for this terminal; the window makes no tab
|
||||
// or pane of its own. Declined when main lacks it (main-first follow-up) or before the snapshot.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN && !known) {
|
||||
const pane = parsePaneKey(owner.paneKey)
|
||||
return (
|
||||
pane?.tabId === owner.tabId &&
|
||||
layoutFramesHoldTerminalPane(worktreeId, { ...owner, leafId: pane.leafId })
|
||||
)
|
||||
return framesShowRecordedPane(worktreeId, owner)
|
||||
}
|
||||
if (bindLivePtyToExactSurface(store, worktreeId, owner)) {
|
||||
if (!known) {
|
||||
@@ -150,6 +149,26 @@ function adoptHostOwnedSurface(
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether main's frames show the terminal in the pane main recorded for it. */
|
||||
function framesShowRecordedPane(worktreeId: string, recorded: LiveTerminalSurfaceOwner): boolean {
|
||||
const pane = parsePaneKey(recorded.paneKey)
|
||||
return (
|
||||
pane?.tabId === recorded.tabId &&
|
||||
layoutFramesHoldTerminalPane(worktreeId, { ...recorded, leafId: pane.leafId })
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether main holds the terminal bound in the pane it recorded, drawn by this window yet or not. */
|
||||
function mainHoldsRecordedPane(worktreeId: string, recorded: LiveTerminalSurfaceOwner): boolean {
|
||||
const pane = parsePaneKey(recorded.paneKey)
|
||||
return Boolean(
|
||||
pane?.tabId === recorded.tabId &&
|
||||
layoutFrameTerminalTab(worktreeId, recorded.tabId)?.terminal?.panes.some(
|
||||
(held) => held.leafId === pane.leafId && held.ptyId === recorded.ptyId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every live workspace PTY the surface that already owns it, minting one
|
||||
* only for a PTY proven to have none.
|
||||
@@ -204,24 +223,19 @@ export async function adoptLiveWorkspacePtySurfaces(
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Switch point: main shows it unless this window holds its recorded pane; a refusal leaves
|
||||
// it without one, so it is declined rather than counted as shown.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const outcome =
|
||||
owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)
|
||||
? 'shown'
|
||||
: await adoptLiveTerminalThroughLayoutOwner(getState, worktreeId, {
|
||||
ptyId,
|
||||
...(owner.recorded ? { recordedPaneKey: owner.recorded.paneKey } : {})
|
||||
})
|
||||
if (outcome === 'shown') {
|
||||
// Switch point: main shows every live terminal no pane shows at startup and window load
|
||||
// (placeUnshownLiveTerminals) and records the pane it chose, so the window only reads that
|
||||
// the frames show it there. A paired server's workspace keeps today's path.
|
||||
if (
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN &&
|
||||
runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) === null
|
||||
) {
|
||||
if (owner.recorded && mainHoldsRecordedPane(worktreeId, owner.recorded)) {
|
||||
surfaced = true
|
||||
continue
|
||||
}
|
||||
if (outcome === 'refused') {
|
||||
} else {
|
||||
declinedPtyIds.push(ptyId)
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
surfaced = true
|
||||
if (owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)) {
|
||||
|
||||
@@ -12,10 +12,7 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRunti
|
||||
import { recordLayoutApply, sendLayoutApply, type WindowLayoutCommand } from './layout-apply-send'
|
||||
import { sendGeneratedTabTitles } from './generated-tab-titles-send'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
|
||||
import {
|
||||
adoptLiveTerminalThroughLayoutOwner,
|
||||
createInitialTerminalThroughLayoutOwner
|
||||
} from '@/store/terminals/terminal-tab-create-through-layout-owner'
|
||||
import { createInitialTerminalThroughLayoutOwner } from '@/store/terminals/terminal-tab-create-through-layout-owner'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/srv/w'
|
||||
const FOLDER_ID = 'folder:f1'
|
||||
@@ -111,13 +108,6 @@ describe.each(PAIRED_WORKSPACES)('$name', ({ workspaceId, rows }) => {
|
||||
expect(createInitialTerminalThroughLayoutOwner(getState, workspaceId, create)).toBe(false)
|
||||
expect(layoutApplyCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('the live terminal adoption sends nothing', async () => {
|
||||
await expect(
|
||||
adoptLiveTerminalThroughLayoutOwner(() => rows, workspaceId, { ptyId: 'pty-1' })
|
||||
).resolves.toBe('as-today')
|
||||
expect(layoutApplyCalls()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/** Every renderer source file that names the `layout.apply` method, with its count. */
|
||||
@@ -151,7 +141,7 @@ describe('layout.apply senders', () => {
|
||||
'runtime/layout-apply-send.ts': 1,
|
||||
// Tests' stand-in for main: it answers layout.apply, never sends it.
|
||||
'runtime/layout-apply-test-transport.test-fixture.ts': 1,
|
||||
'store/terminals/terminal-tab-create-through-layout-owner.ts': 2
|
||||
'store/terminals/terminal-tab-create-through-layout-owner.ts': 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { bindTabTerminalPanesInSession } from '../../../../main/persistence/restoring-sessions/tab-terminal-pane-binding-repair'
|
||||
import { windowViewSaveForMain } from '../../../../main/persistence/window-save-view'
|
||||
import { deriveShutdownReconnectFacts } from '../../../../main/runtime/terminal/recovery/shutdown-reconnect-facts'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { buildWorkspaceSessionHostSnapshots } from '@/lib/workspace-session-host-persistence'
|
||||
import {
|
||||
@@ -163,7 +164,17 @@ function quit({ store, stored }: Launched): StoredPartition[] {
|
||||
const save = snapshots.find(
|
||||
(snapshot) => (snapshot.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId
|
||||
)
|
||||
return { hostId, session: save ? windowViewSaveForMain(session, save.state, hostId) : session }
|
||||
// Main derives what the next startup reconnects from its own terminals and SSH connections.
|
||||
const reconnectFacts = (saved: WorkspaceSessionState) =>
|
||||
deriveShutdownReconnectFacts(saved, {
|
||||
isLiveLocalPty: (ptyId) => ptyId === 'pty-git' || ptyId === 'pty-folder',
|
||||
isKnownExited: () => false,
|
||||
sshStatusByTargetId: new Map([[TARGET_ID, 'connected']])
|
||||
})
|
||||
return {
|
||||
hostId,
|
||||
session: save ? windowViewSaveForMain(session, save.state, hostId, reconnectFacts) : session
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -181,47 +181,5 @@ export function createInitialTerminalThroughLayoutOwner(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A live terminal the host's census finds no pane for, shown through main: main answers with the
|
||||
* pane it already holds for it (a CLI tab this window never mounted), else makes a tab bound to it,
|
||||
* and its frame draws either. 'as-today': the window shows it itself, since a paired server owns
|
||||
* the workspace, main does not own it (a twin), or the call threw (a thrown write is rolled back).
|
||||
* 'refused': main showed nothing, so the terminal stays without a pane.
|
||||
*/
|
||||
export async function adoptLiveTerminalThroughLayoutOwner(
|
||||
getState: () => Parameters<typeof runtimeEnvironmentIdUnlessTwin>[0],
|
||||
worktreeId: string,
|
||||
terminal: { ptyId: string; recordedPaneKey?: string }
|
||||
): Promise<'shown' | 'refused' | 'as-today'> {
|
||||
if (runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) !== null) {
|
||||
return 'as-today'
|
||||
}
|
||||
try {
|
||||
const reply = await callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
|
||||
command: {
|
||||
type: 'createTerminalTab',
|
||||
workspace: worktreeId,
|
||||
tabId: createBrowserUuid(),
|
||||
leafId: createBrowserUuid(),
|
||||
adopt: terminal
|
||||
}
|
||||
})
|
||||
if (reply.ok) {
|
||||
return 'shown'
|
||||
}
|
||||
if (reply.code === 'host_not_owned') {
|
||||
return 'as-today'
|
||||
}
|
||||
console.warn('[layout-owner] live terminal adoption refused:', reply.code)
|
||||
return 'refused'
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[layout-owner] live terminal adoption failed:',
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
return 'as-today'
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's own mark on a first terminal: its spawn does not count as workspace activity. */
|
||||
const ACTIVATION_ROW_FIELDS = { pendingActivationSpawn: true } as const
|
||||
|
||||
@@ -189,10 +189,6 @@ const CreateTerminalTabCommand = z.object({
|
||||
.optional(),
|
||||
/** Only into a workspace with no tab in main's copy; otherwise nothing is created. */
|
||||
initial: z.literal('activation').optional(),
|
||||
/** A live terminal no pane shows: its pane when main holds one, else this tab, bound to it. */
|
||||
adopt: z
|
||||
.object({ ptyId: z.string().min(1), recordedPaneKey: z.string().min(1).optional() })
|
||||
.optional(),
|
||||
split: NewGroupSplit
|
||||
})
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type WorkspaceLayoutModel
|
||||
} from './workspace-layout-model'
|
||||
import { admitsInitialSurface } from './workspace-layout-initial-surface'
|
||||
import { heldPaneOfTerminal } from './workspace-layout-terminal-adoption'
|
||||
import { adoptedLeaf, appliedHeldAdoption } from './workspace-layout-terminal-adoption'
|
||||
import { removeTabFromWorkspace, withWorkspace } from './workspace-layout-removal'
|
||||
|
||||
type RefusedClose = NonNullable<LayoutCommandResult['refused']>[number]
|
||||
@@ -45,10 +45,9 @@ export function createTerminalTab(
|
||||
if (command.initial && !admitsInitialSurface(model, command.workspace, command.initial)) {
|
||||
return applied(model)
|
||||
}
|
||||
const held =
|
||||
command.adopt && heldPaneOfTerminal(model.workspaces[command.workspace], command.adopt)
|
||||
const held = command.adopt && appliedHeldAdoption(model, command.workspace, command.adopt)
|
||||
if (held) {
|
||||
return applied(model, held)
|
||||
return held
|
||||
}
|
||||
const workspace = workspaceOrEmpty(model, command.workspace)
|
||||
const terminals = workspace.tabs.flatMap((tab) => (tab.kind === 'terminal' ? [tab] : []))
|
||||
@@ -73,7 +72,7 @@ export function createTerminalTab(
|
||||
const paneKey = paneKeyOf(id, leafId)
|
||||
if (command.adopt) {
|
||||
// Its terminal already runs: the pane binds to it and nothing starts.
|
||||
const leaves = { ...placed.leaves, [leafId]: { ptyId: command.adopt.ptyId } }
|
||||
const leaves = { ...placed.leaves, [leafId]: adoptedLeaf(command.adopt) }
|
||||
const adopted = withWorkspace(model, command.workspace, { ...placed, leaves })
|
||||
return applied(adopted, { tabId: id, leafId, paneKey })
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ function withCliTab(bound: boolean) {
|
||||
return { context, model: next, tabId: tabId!, leafId: leafId!, paneKey: paneKey! }
|
||||
}
|
||||
|
||||
const adopt = (extra: { recordedPaneKey?: string } = {}) => ({
|
||||
const adopt = (extra: { recordedPaneKey?: string; incarnationId?: string } = {}) => ({
|
||||
type: 'createTerminalTab' as const,
|
||||
workspace: WS,
|
||||
tabId: 'window-tab',
|
||||
@@ -38,11 +38,21 @@ describe('a live terminal no pane shows, through main', () => {
|
||||
expect(applied.ok && applied.effects.startPaneKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('names the pane the host recorded for it while main has not bound it yet', () => {
|
||||
const { context, model, tabId, paneKey } = withCliTab(false)
|
||||
const applied = applyLayoutCommand(model, adopt({ recordedPaneKey: paneKey }), context)
|
||||
it('binds the pane the host recorded for it while main has not bound it yet', () => {
|
||||
const { context, model, tabId, leafId, paneKey } = withCliTab(false)
|
||||
const applied = applyLayoutCommand(
|
||||
model,
|
||||
adopt({ recordedPaneKey: paneKey, incarnationId: 'inc-1' }),
|
||||
context
|
||||
)
|
||||
expect(applied).toMatchObject({ ok: true, result: { tabId, paneKey } })
|
||||
expect(applied.ok && applied.model.workspaces[WS]!.tabs).toHaveLength(1)
|
||||
// Bound, so the pane reattaches to the running terminal instead of starting a second one.
|
||||
expect(applied.ok && applied.model.workspaces[WS]!.leaves?.[leafId]).toEqual({
|
||||
ptyId: PTY,
|
||||
incarnationId: 'inc-1'
|
||||
})
|
||||
expect(applied.ok && applied.effects.startPaneKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('makes a new tab when the recorded pane now shows another terminal', () => {
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
// A live terminal no pane shows (the host's census found it unowned), shown through main: in the
|
||||
// pane main already holds for it, else in a new tab bound to it. Never a second tab for it.
|
||||
|
||||
import { leafIdsOf, locatePane } from './workspace-layout-command-steps'
|
||||
import {
|
||||
applied,
|
||||
leafIdsOf,
|
||||
locatePane,
|
||||
setLeaf,
|
||||
type Applied
|
||||
} from './workspace-layout-command-steps'
|
||||
import type { LayoutCommandResult } from './workspace-layout-command-types'
|
||||
import { paneKeyOf, type WorkspaceLayout } from './workspace-layout-model'
|
||||
import {
|
||||
paneKeyOf,
|
||||
type LayoutLeaf,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceLayoutModel
|
||||
} from './workspace-layout-model'
|
||||
import { withWorkspace } from './workspace-layout-removal'
|
||||
|
||||
export type LiveTerminalAdoption = {
|
||||
ptyId: string
|
||||
/** The pane the host last recorded for it: main holds it, still unbound, before the spawn binds it. */
|
||||
/** The execution the host observed; bound with the terminal so a later restart is told apart. */
|
||||
incarnationId?: string
|
||||
/** The pane the host last recorded for it: main holds it, still unbound, so it binds there. */
|
||||
recordedPaneKey?: string
|
||||
}
|
||||
|
||||
@@ -38,3 +52,27 @@ export function heldPaneOfTerminal(
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
/** The pane data of a pane bound to the adopted terminal. */
|
||||
export function adoptedLeaf({ ptyId, incarnationId }: LiveTerminalAdoption): LayoutLeaf {
|
||||
return incarnationId ? { ptyId, incarnationId } : { ptyId }
|
||||
}
|
||||
|
||||
/** The pane main already holds for the terminal, bound to it; null when main holds none. */
|
||||
export function appliedHeldAdoption(
|
||||
model: WorkspaceLayoutModel,
|
||||
key: string,
|
||||
adoption: LiveTerminalAdoption
|
||||
): Applied | null {
|
||||
const workspace = model.workspaces[key]
|
||||
const held = heldPaneOfTerminal(workspace, adoption)
|
||||
if (!workspace || !held?.leafId) {
|
||||
return null
|
||||
}
|
||||
const leaf = workspace.leaves?.[held.leafId]
|
||||
if (leaf?.ptyId === adoption.ptyId) {
|
||||
return applied(model, held)
|
||||
}
|
||||
const bound = setLeaf(workspace, held.leafId, { ...leaf, ...adoptedLeaf(adoption) })
|
||||
return applied(withWorkspace(model, key, bound), held)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user