diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 20198c451a6..3a4d3b11ca8 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 662fc03a899..e9c684e0dc6 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.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() }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 4ea2c847a32..b5f7afc92ac 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -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 /** Only for app:relaunch, whose app.exit(0) is committed and skips will-quit. */ onBeforeRelaunchExit?: () => void | Promise @@ -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) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 8e2b479cfd7..3e85126ec59 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -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) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index d0cc9547683..4c7125060b4 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -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 ) diff --git a/src/main/ipc/session-window-view-save.test.ts b/src/main/ipc/session-window-view-save.test.ts index 3d5e2784c1e..fa1be97b2fe 100644 --- a/src/main/ipc/session-window-view-save.test.ts +++ b/src/main/ipc/session-window-view-save.test.ts @@ -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() diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index c7c05f56597..3ee8b87f7bc 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -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) => { diff --git a/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts b/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts index 4da936a1362..d90a03b00b5 100644 --- a/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts +++ b/src/main/orcad/orcad-duplicate-pty-repair-startup.test.ts @@ -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() } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index cac21a57bec..770201ed757 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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()) diff --git a/src/main/persistence/terminal-topology/unshown-live-terminal-placement-restart.test.ts b/src/main/persistence/terminal-topology/unshown-live-terminal-placement-restart.test.ts new file mode 100644 index 00000000000..b7afd0b4203 --- /dev/null +++ b/src/main/persistence/terminal-topology/unshown-live-terminal-placement-restart.test.ts @@ -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() + +async function openStore(seed: (window: WindowSession) => void): Promise { + 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 { + const reopened = await reopenTopologyStore(store, directory) + directoryOf.set(reopened, directory) + return reopened +} + +type LivePty = Parameters[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 = {} +) { + const recordedPanes = new Map() + 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) + }) +}) diff --git a/src/main/persistence/window-save-reconnect-facts.test.ts b/src/main/persistence/window-save-reconnect-facts.test.ts new file mode 100644 index 00000000000..06048e48091 --- /dev/null +++ b/src/main/persistence/window-save-reconnect-facts.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/persistence/window-save-view.ts b/src/main/persistence/window-save-view.ts index ad766361ec5..84a0e0f175f 100644 --- a/src/main/persistence/window-save-view.ts +++ b/src/main/persistence/window-save-view.ts @@ -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> = SESSION_FIELD_OWNERS @@ -109,7 +110,8 @@ function withMainLayoutFields( 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( + 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 } diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index f0a5f8ec9b6..3173ed4c158 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -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' diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 5cc738eb655..0b06d5e4326 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -208,4 +208,6 @@ export type PtyControllerInventory = Readonly<{ allLivePtyIds: ReadonlySet terminalIdentityByPtyId: ReadonlyMap queriedHostIds: ReadonlySet + /** Live PTYs the host is killing: still live work, but no longer any pane's to show. */ + exitingPtyIds?: ReadonlySet }> diff --git a/src/main/runtime/terminal/adoption/unshown-live-terminal-placement.ts b/src/main/runtime/terminal/adoption/unshown-live-terminal-placement.ts new file mode 100644 index 00000000000..d9aef7974e5 --- /dev/null +++ b/src/main/runtime/terminal/adoption/unshown-live-terminal-placement.ts @@ -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 + /** The workspaces this host knows: a terminal filed under any other id has no tab to join. */ + workspaceIds: ReadonlySet + } | null> + ptys: () => Iterable + /** 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 + /** 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 { + const bound = new Set() + 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, + inventory: { exitingPtyIds?: ReadonlySet; workspaceIds: ReadonlySet } +): 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 { + 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[2], + placement: LiveTerminalPlacement +): Promise { + // 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 } | null | undefined +): Promise { + 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 { + return new Set(sessions.flatMap((session) => (session.exiting === true ? [session.id] : []))) +} diff --git a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts index f0b7439c664..a1457085873 100644 --- a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts +++ b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts @@ -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) } : {}) } } } diff --git a/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts b/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts new file mode 100644 index 00000000000..d44e0b312ba --- /dev/null +++ b/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts @@ -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 + /** 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 { + const relays: Record = {} + 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() + 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 +} diff --git a/src/main/runtime/workspaces/persisted-surfaces/has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/workspaces/persisted-surfaces/has-exact-persisted-terminal-surface-identity.ts index 610bace7149..46eb8f691d4 100644 --- a/src/main/runtime/workspaces/persisted-surfaces/has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/workspaces/persisted-surfaces/has-exact-persisted-terminal-surface-identity.ts @@ -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 diff --git a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.test.ts b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.test.ts new file mode 100644 index 00000000000..bd46ac67a63 --- /dev/null +++ b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts new file mode 100644 index 00000000000..a43cc6a225e --- /dev/null +++ b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts @@ -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 + + /** Layout-owner build: shows every live local terminal no pane shows (startup, window load). */ + async placeUnshownLiveTerminals(): Promise { + // 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] : [] + }) + }) + } +} diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index b2073cf2aba..1ac7855ee6b 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -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) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index bad266f03ec..36784a1bd5e 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -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 panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (state.headlessBrowserDisplayAvailable) { runtime.setOffscreenBrowserBackend( diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts index a0fdd6a7940..07e8aa7251b 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts @@ -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([]) }) }) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index e12f15a15f0..626f712cd46 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -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)) { diff --git a/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts b/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts index 12c4bdaff0f..1388efe0fb2 100644 --- a/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts +++ b/src/renderer/src/runtime/layout-apply-paired-server-guard.test.ts @@ -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 }) }) }) diff --git a/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts b/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts index f977051f350..ae9aeafa871 100644 --- a/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts +++ b/src/renderer/src/store/terminals/restored-terminal-rebind-restarts.test.ts @@ -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 + } }) } diff --git a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts index b47278f4405..9c5b3b71383 100644 --- a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts @@ -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[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({ 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 diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index c5e12f8e091..79f68c61001 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -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 }) diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index ee2617fe706..f291c0322ee 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -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[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 }) } diff --git a/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts b/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts index 0fda527ce3c..59ca04a5e1d 100644 --- a/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts +++ b/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts @@ -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', () => { diff --git a/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts b/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts index 2db9db7d05f..61d52def33d 100644 --- a/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts +++ b/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts @@ -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) +}