From ed13ca1e6f6085b23d6892627ea969f7119c5b82 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:13:57 -0400 Subject: [PATCH] Layout groundwork (switched off): main starts and restores every pane, and the window only attaches and paints the host's history (#31567) * feat(terminal): the window's store shows main's pane start status (layout-owner build) A status-only frame changes one store entry and no pane tree, so the pane's view stays mounted. * refactor(terminal): keep the frame sync within its line budget * feat(terminal): every pane main starts shows 'starting' from before it is written (layout-owner build) * feat(terminal): a pane main is starting waits and attaches; a failed start shows why (layout-owner build) Fixes owner-visible row 15: a failed agent or setup start no longer opens a plain shell. * test(terminal): iterate store listeners directly * fix(terminal): an exit that reports no code keeps its pane, as lost contact (layout-owner build) * test(terminal): a pane main started attaches to main's terminal at its own grid * test(terminal): detach census tests assert main's move and frame with the switch on * fix(terminal): a restored pane paints history only from its terminal's host, never the window's copy (layout-owner build only) * fix(terminal): main releases a painted cold restore only for the pane's current terminal (layout-owner build only) * test(terminal): typed ack args in the painted cold-restore test * test(terminal): exited-pane tests assert main's close with the switch on These 8 have failed switch-on since #31130 moved the exit close to main. * feat(layout): main starts the panes a client's startPane or wake names, through its one restored-pane starter (layout-owner build only) The model's start and stop effects reach the runtime once the command is written, never the reply. startPane starts a pane whose binding has a start status, and a slept pane from its record. A wake keeps a pane's resume record until its start binds; a failed start keeps it and shows why. With the switch on, main owns a running agent's wake even with a window open. * fix(layout): a window's wake of a pane it resumed itself is record-only, so main does not start it again (layout-owner build only) * test(layout): a client's startPane starts the pane before any other trigger * Revert "fix(terminal): a restored pane paints history only from its terminal's host, never the window's copy (layout-owner build only)" This reverts commit a646d89b265c1d1023a7c9890137f49c82c381a5. * test(terminal): the cold-restore repaint ack names its pane in the layout-owner build * fix(layout): a wake keeps the pane's resume record in the pane-owned form, so a host crash resumes the agent again (layout-owner build only) * test(layout): a failing start in the pane-start runner test throws * feat(terminal): main writes and starts every split of a pane it holds; the window attaches (layout-owner build) The window's split, a startup split with its command, and a CLI split all go through main's terminal.split; main's frame mounts the pane, with or without a window intent. Deletes the window's split wait and withdraw, the split appear intents, and the queued-split skip and cancel. The e2e helper uses the real split. A startup split leaves focus on its source pane. * fix(terminal): the attach-only wait leaves sleeping, resuming and held panes to their restore path * fix(terminal): a split pane attaches to the terminal main bound it to; a taken pane id is refused Also restores the proof that a split closed while it starts gets one answer and leaves no pane or start status behind. * Reapply "fix(terminal): a restored pane paints history only from its terminal's host, never the window's copy (layout-owner build only)" This reverts commit 420e605ad00bdec5a53f55aa0ada84d135a9ff29. * fix(layout): a trigger between a restored pane's start and its bind write joins that start, not a second one (layout-owner build only) * fix(terminal): a window split main cannot start still shows its pane, failed, with why * fix(terminal): keep the switch import the D1b-1 merge dropped from the layout restore * feat(layout): main's starter shows a restored pane starting before any await, and a failed start with its reason; startPane starts a pane whose bound terminal ended (layout-owner build only) * feat(terminal): a window's attach-only view of a pane attaches to its terminal or starts nothing, keeping the binding for main's start (layout-owner build only) * feat(terminal): a restored pane with no live terminal asks main to start it and attaches once main binds it; the window's wake, hibernated wake, restart-on-show and gone-terminal fallbacks route through main (layout-owner build only) * fix(terminal): check a taken pane id first, keep the start's own error, clear a gone pane's status Three test stores gain the pane-start field the window's store always has. * test(terminal): the admission stub answers whether its pane is still written; a closed pane's failed start clears * test(terminal): with the switch on, the window's restores, wakes and restart replacements ask main to start the pane and attach to the terminal main binds, starting and resuming nothing themselves * refactor(terminal): the rebound workspaces' bindings show through one call in the bindings module * fix(terminal): a held pane connects as today so main's refusal holds it; main's start follows output; a capacity death after main's resume keeps the resume (layout-owner build only) * fix(layout): main passes its start options only for commands that start panes; a windowed open resumes the agent under main (layout-owner build only) * refactor(terminal): break the pane start module cycle; typed test fixtures * test(terminal): a pane main wrote and the user closed mid-start drops its failed status * fix(terminal): every waiter on main's start runs, a failed or refused start settles and is asked again on the next view, a hidden gone pane waits for its view, a twin main hands back stays on today's path, and lost contact never starts a pane (layout-owner build only) * test(terminal): iterate the store listeners directly * test(terminal): a cold-restore paint applies no window resume with the switch on * fix(terminal): a split main starts is listed to phones and session.tabs without a window's report * test(terminal): name the main pane start test helper as a fixture * fix(terminal): a reconnect shows its pane starting and why it failed; a lost owner race shows no failure; a split no one asked to see keeps the phones' tab * fix(terminal): a kept create another owner won shows no failure; the window attaches to the winner * fix(terminal): a pane main starts at relaunch keeps its full history and starts before the window's graph lists its tab (layout-owner build only) * feat(terminal): main replaces the empty shell a hibernated agent left and resumes the agent when a view asks; a start main could not attach shows main's own terminal_not_started (layout-owner build only) * test(terminal): main seeds its copy of a restored pane even when a window declared one, with the switch on * fix(terminal): a pane main started hidden paints once when its view attaches (switch-on builds only) The view's attach snapshot, taken after main unmarks the hidden gate, already holds the bytes main dropped, so the reveal no longer asks a second restore from main's buffer. * refactor(terminal): main's pane start reads the pane from its own layout, not the client projection (switch-on builds only) * fix(terminal): a pane restart stops its terminal before writing the pane unbound, so a stop that fails keeps the pane (switch-on builds only) * test(terminal): a start main could not bind shows terminal_not_started through the window's wait, from one copy site (switch-on builds only) * fix(terminal): main's pane start resumes an agent recorded under the pane's number, as the window did; stale switch-on tests show main's start and the known banner loss (switch-on builds only) * fix(terminal): review round: a visible view alone retires main's hidden-start drops, by episode; main reads a split pane's numbered record by its number; the window keeps a shell main kept (switch-on builds only) * fix(terminal): main's start of a pane a window view is attaching to leaves it visible, so the view paints once (switch-on builds only) * fix(terminal): a window view that attaches while main starts its pane takes main's start, with no second provider attach (switch-on builds only) * fix(terminal): review round: a view takes main's start only when it carries a payload and still owns its terminal; attaching views are counted, keep main's pre-spawn mark off, and a failed attach hides only what it kept visible (switch-on builds only) * test(terminal): checked test doubles in the view-join and numbered-record tests; reattach options fit the file budget * test(e2e): with the switch on, the mount-authority spec keeps the window's bindings, which only main's frames write Claude-Session: W5b * test(e2e): the mount-authority spec fits its line budget; the layout-owner build check is one helper Claude-Session: W5b * test(layout): an unbound pane holding a resume record starts and keeps the record, since main's start resumes from it Claude-Session: W5b * fix(terminal): a pane main resumed holds a running agent's record, so it reads awake, not sleeping (switch-on builds only) Claude-Session: W5b * fix(terminal): in a split where no pane owns a numbered record, main resumes the tab's one conversation in pane 1 only (switch-on builds only) Claude-Session: W5b * fix(terminal): main marks only the record a resume started from live, never a sleep captured after it (switch-on builds only) Claude-Session: W5b * test(terminal): with the switch on, a windowless resume leaves the pane's record a running agent's Claude-Session: W5b * test(terminal): the resumed-record test double typechecks Claude-Session: W5b * refactor(terminal): with the switch off, main's pane start reads no resume record for the live mark Claude-Session: W5b * refactor(terminal): the merged files fit their line budget: the folder-fallback notice and a hidden pane's wait for its first view are helpers, and the answering-transport lookup moves to its one test caller Claude-Session: W5b * refactor(runtime): the window's layout apply fits the runtime's line budget after the main merge Claude-Session: W5b --- .../pty-attach-view-joins-main-start.test.ts | 144 +++++++ src/main/ipc/pty-hidden-delivery-gate.ts | 5 + ...nted-cold-restore-ack-layout-owner.test.ts | 87 +++++ .../ipc/pty-restore-record-seeding.test.ts | 15 +- .../pty-runtime-hidden-at-spawn-mark.test.ts | 32 ++ src/main/ipc/pty/delivery/visibility-state.ts | 3 + .../pty/ipc/painted-cold-restore-ack.test.ts | 48 +++ .../ipc/pty/ipc/painted-cold-restore-ack.ts | 37 ++ src/main/ipc/pty/ipc/resize-visibility.ts | 11 +- src/main/ipc/pty/ipc/spawn-begin.ts | 18 +- src/main/ipc/pty/ipc/spawn-execute.ts | 5 +- src/main/ipc/pty/ipc/spawn-run.ts | 3 + src/main/ipc/pty/ipc/spawn-types.ts | 3 + src/main/ipc/pty/ipc/spawn.ts | 9 + src/main/ipc/pty/pane/adopt-stable.ts | 3 +- .../ipc/pty/pane/attach-only-view.test.ts | 196 ++++++++++ src/main/ipc/pty/pane/attach-only-view.ts | 95 +++++ src/main/ipc/pty/pane/spawn-reservation.ts | 23 +- src/main/ipc/pty/pane/stable-owner.ts | 2 + src/main/ipc/pty/pane/stable-pane-attach.ts | 5 + .../pty/pane/terminal-spawn-restore.test.ts | 37 ++ .../ipc/pty/pane/terminal-spawn-restore.ts | 6 +- .../ipc/pty/runtime/spawn-commit-response.ts | 14 +- .../ipc/pty/runtime/spawn-hidden-delivery.ts | 16 +- .../layout-command-commit.ts | 37 +- .../layout-command-persistence.ts | 45 ++- .../terminal-layout-sleep-wake.test.ts | 69 +++- .../terminal-pane-admission.ts | 19 +- .../terminal-topology-commit.ts | 8 +- ...t-command-pane-starts-layout-owner.test.ts | 366 ++++++++++++++++++ .../orca-runtime-test-fixtures.spec.ts | 36 +- ...ssion-tabs-windowless-sleep-resume.spec.ts | 18 +- src/main/runtime/orca-runtime.ts | 6 +- src/main/runtime/runtime-layout-apply.ts | 18 +- .../apply-mobile-session-tab-navigation.ts | 28 +- .../projection/pane-sleeping-record.test.ts | 109 ++++++ .../projection/pane-sleeping-record.ts | 57 +++ .../projection/restored-pane-materialize.ts | 62 ++- .../window-wake-sleep-record.test.ts | 44 +++ .../projection/window-wake-sleep-record.ts | 26 ++ .../terminal/launch/failed-start-pane.ts | 61 ++- .../layout-command-pane-starts.test.ts | 48 +++ .../recovery/layout-command-pane-starts.ts | 63 +++ .../recovery/restored-pane-start.test.ts | 46 ++- .../terminal/recovery/restored-pane-start.ts | 29 ++ src/preload/api/pty-api.ts | 6 +- src/preload/api/pty-bridge-session-control.ts | 5 +- .../terminal-pane/ipc-pty-spawn-request.ts | 1 + ...ty-connection-agent-session-resume.spec.ts | 60 +++ ...nnection-cold-restore-agent-resume.spec.ts | 36 ++ ...ty-connection-cold-restore-repaint.spec.ts | 9 +- ...ection-cold-restore-resume-command.spec.ts | 27 +- .../pty-connection-hibernation-wake.spec.ts | 142 +++++++ .../pty-connection-hidden-replay.test.ts | 9 +- ...-connection-hidden-sleeping-resume.spec.ts | 56 ++- .../pty-connection-input-and-repaint.test.ts | 9 +- .../pty-connection-layout-owner-wake.test.ts | 40 +- ...connection-main-pane-restore-start.test.ts | 264 +++++++++++++ .../pty-connection-main-pane-start.test.ts | 63 ++- .../pty-connection-pty-exit-teardown.spec.ts | 26 +- .../pty-connection-reattach-binding.test.ts | 33 ++ ...pty-connection-reattach-mode-reset.spec.ts | 30 +- ...pty-connection-restart-replacement.spec.ts | 33 +- ...-connection-sleeping-resume-banner.spec.ts | 57 +++ ...ty-connection-spawn-deferred-sleep.test.ts | 73 +++- ...onnection-test-main-pane-start-fixtures.ts | 134 +++++++ .../pty-connection/apply-reattach-payload.ts | 24 +- .../deferred-cold-restore-and-snapshot.ts | 20 +- .../deferred-session-reattach-connect.ts | 72 +++- ...session-reattach-newer-daemon-hold.test.ts | 20 +- ...-session-reattach-startup-snapshot.test.ts | 16 + .../empty-reattach-retire-evidence.test.ts | 6 +- .../empty-reattach-retire-evidence.ts | 10 +- .../in-place-resume-hold-wiring.test.ts | 29 +- .../pty-connection/main-pane-start-request.ts | 144 +++++++ .../main-pane-start-wait.test.ts | 97 +++++ .../pty-connection/main-pane-start-wait.ts | 59 ++- .../pty-connection/main-starts-pane.ts | 27 ++ .../painted-cold-restore-ack.test.ts | 47 +++ .../painted-cold-restore-ack.ts | 20 + .../reattach-payload-context.test.ts | 9 +- .../reattach-payload-session.ts | 1 + ...result-handler-park-reveal-verdict.test.ts | 42 +- .../pty-connection/reattach-result-handler.ts | 18 +- .../pty-connection/run-deferred-connect.ts | 4 +- .../session-reconcile-dispose.ts | 1 + .../pty-connection/sleeping-record-access.ts | 41 +- .../pty-connection/spawn-deferred-sleep.ts | 5 +- .../startup-cwd-fallback-notice.ts | 23 +- .../terminal-pane/pty-transport-types.ts | 2 + .../terminal-pane-layout-restore.test.ts | 81 ++++ .../terminal-pane-layout-restore.ts | 16 +- .../src/lib/sleeping-agent-pane-ownership.ts | 10 +- src/renderer/src/runtime/layout-apply-send.ts | 3 + ...ayout-apply-test-transport.test-fixture.ts | 8 +- .../sleeping-records-to-layout-owner.test.ts | 4 +- .../terminal-sleep-layout-owner.test.ts | 5 +- .../terminals/terminal-sleep-layout-owner.ts | 12 +- src/shared/hibernation-done-record.ts | 6 + src/shared/painted-restore-ack.ts | 5 + src/shared/pane-attach-only-view.ts | 2 + .../rpc-contract/workspace-layout-params.ts | 3 +- .../workspace-layout-sleep-wake-params.ts | 7 +- .../sleeping-record-legacy-pane-key.test.ts | 51 +++ src/shared/sleeping-record-legacy-pane-key.ts | 59 +++ .../workspace-layout-command-types.ts | 10 +- .../workspace-layout-commands.test.ts | 61 ++- .../workspace-layout-process-commands.ts | 34 +- .../workspace-layout-resume-record.test.ts | 10 +- tests/e2e/helpers/layout-owner-build.ts | 7 + ...ackground-terminal-mount-authority.spec.ts | 4 +- 111 files changed, 3957 insertions(+), 303 deletions(-) create mode 100644 src/main/ipc/pty-attach-view-joins-main-start.test.ts create mode 100644 src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts create mode 100644 src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts create mode 100644 src/main/ipc/pty/ipc/painted-cold-restore-ack.ts create mode 100644 src/main/ipc/pty/pane/attach-only-view.test.ts create mode 100644 src/main/ipc/pty/pane/attach-only-view.ts create mode 100644 src/main/ipc/pty/pane/terminal-spawn-restore.test.ts create mode 100644 src/main/runtime/layout-command-pane-starts-layout-owner.test.ts create mode 100644 src/main/runtime/session-lifecycle/projection/pane-sleeping-record.test.ts create mode 100644 src/main/runtime/session-lifecycle/projection/pane-sleeping-record.ts create mode 100644 src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.test.ts create mode 100644 src/main/runtime/terminal/recovery/layout-command-pane-starts.test.ts create mode 100644 src/main/runtime/terminal/recovery/layout-command-pane-starts.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-main-pane-restore-start.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-test-main-pane-start-fixtures.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-request.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/main-starts-pane.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.test.ts create mode 100644 src/shared/hibernation-done-record.ts create mode 100644 src/shared/painted-restore-ack.ts create mode 100644 src/shared/pane-attach-only-view.ts create mode 100644 src/shared/sleeping-record-legacy-pane-key.test.ts create mode 100644 src/shared/sleeping-record-legacy-pane-key.ts create mode 100644 tests/e2e/helpers/layout-owner-build.ts diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts new file mode 100644 index 00000000000..d300c857e87 --- /dev/null +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -0,0 +1,144 @@ +// Layout-owner build: a window view that attaches while main starts its pane takes main's start, +// with no second provider attach (one round trip, as the window's own cold restore was). +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty/register-handlers' +import { setLocalPtyProvider } from './pty/provider/registry' + +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type PaneArgs = { worktreeId: string; connectionId: string | null; tabId: string; leafId: string } +type RuntimeSpawnController = { + claimStablePaneCreate(args: PaneArgs): () => void + spawn(args: Record): Promise<{ id: string }> +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test doubles implement only the members these spawn paths read. +const testDouble = (value: unknown): T => value as T + +const PANE: PaneArgs = { + worktreeId: 'repo-1::/tmp/view-join', + connectionId: null, + tabId: 'tab-view-join', + leafId: '55555555-5555-4555-8555-555555555555' +} + +describe('a window view of a pane main is starting', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + + /** Main starts the pane while the window's view of it waits; resolves the view's attach. */ + async function viewDuringMainStart(startResult: Record) { + const providerSpawn = vi.fn(async (opts: { sessionId?: string }) => ({ + id: opts.sessionId ?? 'pty-main', + ...startResult + })) + setLocalPtyProvider( + testDouble({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + }) + ) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value: RuntimeSpawnController) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_main'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + seedHeadlessTerminal: vi.fn() + } + registerPtyHandlers(testDouble(mainWindow), testDouble(runtime)) + const main = testDouble(controller) + const releaseClaim = main.claimStablePaneCreate(PANE) + const view = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + ...PANE, + sessionId: 'pty-main', + attachOnlyView: true + }) + await main.spawn({ cols: 80, rows: 24, cwd: '/tmp/view-join', ...PANE, sessionId: 'pty-main' }) + releaseClaim() + return { view: await view, providerSpawn } + } + + it('attaches to the terminal main starts, with one provider request', async () => { + const coldRestore = { scrollback: 'restored history', cwd: '/tmp/view-join' } + const { view, providerSpawn } = await viewDuringMainStart({ coldRestore }) + expect(view).toMatchObject({ id: 'pty-main', isReattach: true, coldRestore }) + expect(providerSpawn).toHaveBeenCalledOnce() + }) + + it("attaches as today when main's start carries no payload to paint", async () => { + // A fresh start: nothing to paint, so the view's own attach takes the provider's snapshot. + const { view, providerSpawn } = await viewDuringMainStart({}) + expect(view).toMatchObject({ id: 'pty-main' }) + expect(providerSpawn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index 212860b7ab8..79086652b10 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -145,6 +145,11 @@ export function recordHiddenRendererPtyDataDrop( return { shouldEmitRestoreMarker: true, hiddenDropEpisode: episode } } +/** The open drop episode, if bytes were dropped since the last restore painted. */ +export function hiddenRendererPtyDropEpisode(id: string): number | undefined { + return droppedSinceHiddenPtys.get(id)?.episode +} + /** Renderer process replaced (reload / crash): its ref-counted interest * holds and hidden marks died with it, so keeping them would gate (or * force-feed) PTYs no live renderer party asked about. Runtime-owned marks diff --git a/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts b/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts new file mode 100644 index 00000000000..1b8971f4574 --- /dev/null +++ b/src/main/ipc/pty-painted-cold-restore-ack-layout-owner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { onMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty/register-handlers' +import { setLocalPtyProvider } from './pty/provider/registry' +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +// Layout-owner build: a view's painted-restore ack is main's to apply, for the pane's current terminal. +const PANE = { worktreeId: 'repo::/wt', tabId: 'tab-1', leafId: 'leaf-1' } + +describe('a painted cold-restore ack in the layout-owner build', () => { + const { mainWindow } = setupPtyIpcSuite() + + function ackWith(boundPtyId: string, args: unknown): ReturnType { + const ackColdRestore = vi.fn() + const isRestoredPaneBoundTo = (_pane: unknown, ptyId: string): boolean => ptyId === boundPtyId + // Only the binding read matters here; every other runtime hook the handlers install is inert. + const runtime = new Proxy( + {}, + { get: (_target, key) => (key === 'isRestoredPaneBoundTo' ? isRestoredPaneBoundTo : vi.fn()) } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the Proxy answers every runtime hook the handlers install. + registerPtyHandlers(mainWindow, runtime as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ack listener calls only ackColdRestore. + setLocalPtyProvider({ ackColdRestore } as never) + const call = onMock.mock.calls.findLast((entry: unknown[]) => entry[0] === 'pty:ackColdRestore') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.on registered this channel with an (event, args) listener. + const listener = call?.[1] as (event: unknown, args: unknown) => void + listener(null, args) + return ackColdRestore + } + + it('releases the terminal service copy for the pane’s current terminal', () => { + expect(ackWith('pty-1', { id: 'pty-1', pane: PANE })).toHaveBeenCalledWith('pty-1') + }) + + it('keeps it when a stale view acks a terminal the pane no longer binds', () => { + expect(ackWith('pty-new', { id: 'pty-old', pane: PANE })).not.toHaveBeenCalled() + }) + + it('keeps it for an ack that names no pane', () => { + expect(ackWith('pty-1', { id: 'pty-1' })).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty-restore-record-seeding.test.ts b/src/main/ipc/pty-restore-record-seeding.test.ts index 16018886c12..300aafde9ab 100644 --- a/src/main/ipc/pty-restore-record-seeding.test.ts +++ b/src/main/ipc/pty-restore-record-seeding.test.ts @@ -11,6 +11,7 @@ import { import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { getDefaultWorkspaceSession } from '../../shared/constants' import { makePaneKey } from '../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RuntimeResolvedWorktreeCache } from '../runtime/runtime-resolved-worktree-cache' import type { ResolvedWorktree } from '../runtime/runtime-worktree-path-identity' @@ -282,8 +283,18 @@ describe('registerPtyHandlers', () => { }) // The renderer owns the emulator snapshot here — but the list/read records - // are main-side only, so the record seed must still run. - expect(runtime.seedHeadlessTerminal).not.toHaveBeenCalled() + // are main-side only, so the record seed must still run. Under main's layout, main's copy is + // the history every view repaints from, so it is seeded too. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(runtime.seedHeadlessTerminal).toHaveBeenCalledWith( + 'pty-gated-reattach', + 'gated snapshot\r\n', + { cols: 80, rows: 24 }, + {} + ) + } else { + expect(runtime.seedHeadlessTerminal).not.toHaveBeenCalled() + } expect(runtime.seedTerminalRestoreTail).toHaveBeenCalledWith('pty-gated-reattach', { text: 'gated snapshot\r\n', lastTitle: 'gated-title' diff --git a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts index 185648b3cca..876cc005a91 100644 --- a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts @@ -4,6 +4,7 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { isHiddenRendererPty } from './pty-hidden-delivery-gate' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { registerPtyHandlers } from './pty/register-handlers' +import { attachViewAfterHiddenStart } from './pty/pane/attach-only-view' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -195,6 +196,37 @@ describe('runtime-controller spawn: hidden until a renderer view mounts', () => ) }) + it('does not hide a PTY main starts while a window view attaches to it (layout-owner build)', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + let mintedSessionId: string | undefined + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + mintedSessionId = options.sessionId + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + const controller = installController(runtime) + const spawnPromise = controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + await vi.waitFor(() => expect(mintedSessionId).toBeDefined()) + const attachGate = makeDeferred() + // The view attaches before main's start commits, as a window that sees the binding first. + const viewAttach = attachViewAfterHiddenStart( + mintedSessionId!, + (id, hidden) => getPtySetHiddenRendererPtyListener()(null, { id, hidden }), + async () => { + await attachGate.promise + return { id: mintedSessionId!, snapshot: '' } + } + ) + spawnGate.resolve() + const result = await spawnPromise + attachGate.resolve() + await viewAttach + + expect(isHiddenRendererPty(result.id)).toBe(false) + }) + it('clears the pre-spawn mark when the runtime spawn fails', async () => { const runtime = createRuntimeMock() const daemon = installObservableDaemonTestProvider() diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts index 5c7edcead94..ee27617a195 100644 --- a/src/main/ipc/pty/delivery/visibility-state.ts +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -9,6 +9,9 @@ export const interactiveOutputCharsByPty = new Map() export const activeRendererPtys = new Set() export const visibleRendererPtys = new Set() export const rendererVisibilityKnownPtys = new Set() +// Layout-owner build: terminals visible window views are attaching to, so main's start of them +// stays visible; `skippedHide` records that it did, so a failed attach hides it again. +export const attachingRendererViewPtys = new Map() // Why null-init + wrapper fns: see debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661). let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null diff --git a/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts b/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts new file mode 100644 index 00000000000..162604b1a9e --- /dev/null +++ b/src/main/ipc/pty/ipc/painted-cold-restore-ack.test.ts @@ -0,0 +1,48 @@ +// A view's painted-restore ack releases the terminal service's in-memory copy. Layout-owner builds +// release it only for the terminal main binds the named pane to now; no view, no ack. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaintedRestoreAck } from '../../../../shared/painted-restore-ack' + +const ackColdRestore = vi.fn() +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: () => ({ ackColdRestore }) +})) + +const PANE = { worktreeId: 'repo::/wt', tabId: 'tab-1', leafId: 'leaf-1' } + +async function ackWithSwitch( + on: boolean, + args: Partial, + boundPtyId: string | null +): Promise { + vi.resetModules() + vi.doMock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: on })) + const { acknowledgePaintedColdRestore } = await import('./painted-cold-restore-ack') + acknowledgePaintedColdRestore(args, (_pane, ptyId) => ptyId === boundPtyId) +} + +describe('acknowledgePaintedColdRestore', () => { + beforeEach(() => { + ackColdRestore.mockClear() + }) + + it('releases the copy for any painted terminal with the switch off', async () => { + await ackWithSwitch(false, { id: 'pty-old' }, null) + expect(ackColdRestore).toHaveBeenCalledWith('pty-old') + }) + + it('releases the copy for the pane’s current terminal in the layout-owner build', async () => { + await ackWithSwitch(true, { id: 'pty-1', pane: PANE }, 'pty-1') + expect(ackColdRestore).toHaveBeenCalledWith('pty-1') + }) + + it('keeps the copy when a stale view acks a terminal its pane no longer binds', async () => { + await ackWithSwitch(true, { id: 'pty-old', pane: PANE }, 'pty-new') + expect(ackColdRestore).not.toHaveBeenCalled() + }) + + it('keeps the copy for an ack that names no pane in the layout-owner build', async () => { + await ackWithSwitch(true, { id: 'pty-1' }, 'pty-1') + expect(ackColdRestore).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts b/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts new file mode 100644 index 00000000000..cf0482a7c8a --- /dev/null +++ b/src/main/ipc/pty/ipc/painted-cold-restore-ack.ts @@ -0,0 +1,37 @@ +import type { PaintedRestoreAck, PaintedRestorePane } from '../../../../shared/painted-restore-ack' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { tryGetProviderForPty } from '../provider/registry' + +function isPane(value: unknown): value is PaintedRestorePane { + if (typeof value !== 'object' || value === null) { + return false + } + const pane: Partial> = value + return ( + typeof pane.worktreeId === 'string' && + typeof pane.tabId === 'string' && + typeof pane.leafId === 'string' + ) +} + +/** + * A view painted a cold restore: drop the terminal service's in-memory copy (checkpoint.json + * keeps the history across restarts). No view, no ack. + */ +export function acknowledgePaintedColdRestore( + args: Partial | null | undefined, + isPaneBoundTo: ((pane: PaintedRestorePane, ptyId: string) => boolean) | undefined +): void { + const id = args?.id + if (typeof id !== 'string' || !id) { + return + } + // Switch point: main releases it only for the pane's current terminal; a stale view's ack keeps it. + if (ORCA_LAYOUT_OWNER_IS_MAIN && !(isPane(args?.pane) && isPaneBoundTo?.(args.pane, id))) { + return + } + const provider = tryGetProviderForPty(id) + if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { + provider.ackColdRestore(id) + } +} diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index af8a88241f2..eb75483c6c5 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -29,6 +29,8 @@ import { PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS } from '../delivery/constants' import { applyCumulativeAck } from '../delivery/accounting' import { sendModelRestoreNeededMarker } from '../delivery/payload' import { isMainWindowPtyIpcEvent } from './write-input' +import { acknowledgePaintedColdRestore } from './painted-cold-restore-ack' +import type { PaintedRestoreAck } from '../../../../shared/painted-restore-ack' import type { PtyIpcSession } from '../session' export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { @@ -72,11 +74,10 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. ipcMain.removeAllListeners('pty:ackColdRestore') - ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { - const provider = tryGetProviderForPty(args.id) - if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { - provider.ackColdRestore(args.id) - } + ipcMain.on('pty:ackColdRestore', (_event, args: Partial | undefined) => { + acknowledgePaintedColdRestore(args, (pane, ptyId) => + Boolean(runtime?.isRestoredPaneBoundTo(pane, ptyId)) + ) }) // Why the ack and not the serve: the pane can dispose across the serialize + IPC round trip, diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 37f5f660297..51c153f3d71 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -1,3 +1,5 @@ +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { attachOnlyViewOf } from '../pane/attach-only-view' import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launch-pane-attachment' import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' @@ -107,7 +109,21 @@ export async function beginPtyIpcSpawn( ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) : undefined if (pendingRuntimeCreate) { - await pendingRuntimeCreate.promise + const created = await pendingRuntimeCreate.promise + // Why: main's start of this pane carries the payload a view paints from; attaching to the + // provider again adds a round trip. A start that carries none, or no longer owns its + // terminal, leaves the view to attach as today. + if ( + attachOnlyViewOf(args) && + created && + created.id === args.sessionId && + (created.coldRestore || typeof created.snapshot === 'string') && + ptyOwnership.has(created.id) && + (created.incarnationId === undefined || + ptyIncarnationById.get(created.id) === created.incarnationId) + ) { + return created + } } } ctx.earlyStablePaneOwner = diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index 020e729cc59..80cf7a55713 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -9,6 +9,7 @@ import { import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' import { assertSpawnReplyWasLive } from '../pane/agent-session-owners' import { failPtySpawnExecute } from '../spawn-execute-failure' +import { attachOnlyViewOf, attachViewWithoutOwner } from '../pane/attach-only-view' import type { PtyIpcSpawnState } from './spawn-state' export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { @@ -64,7 +65,9 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { if (!ctx.preAdoptedStablePane && ctx.paneAgentLaunch) { ctx.paneAgentLaunch.dispatched = true } - ctx.result = await ctx.provider.spawn(ctx.spawnOptions) + ctx.result = attachOnlyViewOf(args) + ? await attachViewWithoutOwner(ctx.provider, ctx.spawnOptions) + : await ctx.provider.spawn(ctx.spawnOptions) ctx.stablePaneOwner = null ctx.rejectedRegistrationCandidate = ctx.result if (ctx.pendingRegistrationPtyId !== ctx.result.id) { diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 0405fef3e65..d1107150eee 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -21,6 +21,7 @@ import { assemblePtyIpcSpawnEnv } from './spawn-env' import { preparePtyIpcQoderCommand } from './spawn-qoder-command' import { buildPtyIpcSpawnOptions } from './spawn-options' import { executePtyIpcSpawn } from './spawn-execute' +import { attachOnlyViewOf } from '../pane/attach-only-view' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' @@ -121,6 +122,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg connectionId: args.connectionId, renderer: true, keepAbsentBinding: keepAbsentBindingForSwap(ctx), + attachOnlyView: attachOnlyViewOf(args), spawnFresh: async (scope) => (completed = await complete(scope)) }) if (adopted) { @@ -161,6 +163,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg reservation: ctx.paneSpawnReservation ?? undefined, stoppedPtyId: ctx.replacedPaneOwner?.ptyId, keepAbsentBinding: keepAbsentBindingForSwap(ctx), + attachOnlyView: attachOnlyViewOf(args), spawnFresh: async (operation) => (freshResult = await fresh(operation)) }) if (adopted) { diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 805de15d6c9..9715ac4f998 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -39,6 +39,8 @@ export type PtySpawnIpcArgs = { connectionId?: string | null worktreeId?: string sessionId?: string + // Layout-owner build: a view of a pane main starts; it attaches to `sessionId` or starts nothing. + attachOnlyView?: boolean shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalKittyKeyboardProtocol?: boolean @@ -86,6 +88,7 @@ export type AdoptStablePaneArgs = { spawnFresh?: (operation: PaneSpawnOperation) => Promise /** See StablePaneSpawnContext; adoption holds the pane's reservation through its fresh launch. */ keepAbsentBinding?: (dead: ReplacedPaneOwner) => void + attachOnlyView?: true } export type AdoptStablePaneResult = { diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index 299094c4bef..5c21e7a330a 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -2,6 +2,7 @@ import { getPtyIpc } from '../../pty-host-bindings' import { runPtyIpcSpawn } from './spawn-run' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' +import { attachOnlyViewOf, attachViewAfterHiddenStart } from '../pane/attach-only-view' export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { const ipcMain = getPtyIpc() @@ -14,6 +15,14 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } + // Switch point: a visible attach-only view of a main-started pane. + if (attachOnlyViewOf(args) && args.sessionId && args.initiallyHidden !== true) { + return attachViewAfterHiddenStart( + args.sessionId, + deps.transitionSpawnHiddenRendererPtyDeliveryState, + () => runPtyIpcSpawn(deps, args) + ) + } return runPtyIpcSpawn(deps, args) }) } diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts index a572071ae22..525f641ffa5 100644 --- a/src/main/ipc/pty/pane/adopt-stable.ts +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -139,7 +139,8 @@ export async function adoptStablePane( resolveOwner, preAllocatedHandle: args.preAllocatedHandle, renderer: args.renderer, - keepAbsentBinding: args.keepAbsentBinding + keepAbsentBinding: args.keepAbsentBinding, + attachOnlyView: args.attachOnlyView }) if (adopted) { args.onAdopted?.(adopted.result) diff --git a/src/main/ipc/pty/pane/attach-only-view.test.ts b/src/main/ipc/pty/pane/attach-only-view.test.ts new file mode 100644 index 00000000000..53d49d4b264 --- /dev/null +++ b/src/main/ipc/pty/pane/attach-only-view.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { IPtyProvider } from '../../../providers/pty-provider-contract' +import { PANE_NOT_LIVE_ERROR } from '../../../../shared/pane-attach-only-view' +import { + attachOnlyViewOf, + attachViewAfterHiddenStart, + attachViewWithoutOwner +} from './attach-only-view' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + isHiddenRendererPty, + isRuntimeOwnedHiddenRendererPty, + markHiddenRendererPty, + markRuntimeOwnedHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + unmarkHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { attachingRendererViewPtys } from '../delivery/visibility-state' +import { attemptStablePaneAttach } from './stable-pane-attach' +import type { StablePaneOwner } from './stable-owner' + +const OWNER: StablePaneOwner = { + tabId: 'tab-1', + leafId: '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e', + ptyId: 'pty-1', + hasPersistedBinding: true +} + +function providerOf(spawn: ReturnType): IPtyProvider { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a view of a pane calls only provider.spawn. + return { spawn } as unknown as IPtyProvider +} + +describe("a window's attach-only view of a pane main starts", () => { + it('attaches to a live owner', async () => { + const spawn = vi.fn().mockResolvedValue({ id: OWNER.ptyId, isReattach: true }) + const attempt = await attemptStablePaneAttach({ + runtime: undefined, + provider: providerOf(spawn), + spawnOptions: { cols: 80, rows: 24, command: 'claude --resume s' }, + owner: OWNER, + resolveOwner: () => OWNER, + attachOnlyView: true + }) + expect(attempt).toMatchObject({ attached: { owner: OWNER } }) + expect(spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ sessionId: OWNER.ptyId, attachOnly: true, command: undefined }) + ) + }) + + it("starts nothing and keeps the binding when the owner's terminal is gone", async () => { + const spawn = vi.fn().mockRejectedValue(new SessionNotFoundError(OWNER.ptyId)) + const keepAbsentBinding = vi.fn() + await expect( + attemptStablePaneAttach({ + runtime: undefined, + provider: providerOf(spawn), + spawnOptions: { cols: 80, rows: 24 }, + owner: OWNER, + resolveOwner: () => OWNER, + keepAbsentBinding, + attachOnlyView: true + }) + ).rejects.toThrow(PANE_NOT_LIVE_ERROR) + expect(spawn).toHaveBeenCalledTimes(1) + expect(keepAbsentBinding).not.toHaveBeenCalled() + }) + + it('with no recorded owner, attaches only to the named terminal', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-1', isReattach: true }) + await attachViewWithoutOwner(providerOf(spawn), { + cols: 80, + rows: 24, + sessionId: 'pty-1', + command: 'claude --resume s' + }) + expect(spawn).toHaveBeenCalledExactlyOnceWith({ + cols: 80, + rows: 24, + sessionId: 'pty-1', + attachOnly: true + }) + }) + + it('with no recorded owner and no live terminal, starts nothing', async () => { + const spawn = vi.fn().mockRejectedValue(new SessionNotFoundError('pty-1')) + await expect( + attachViewWithoutOwner(providerOf(spawn), { cols: 80, rows: 24, sessionId: 'pty-1' }) + ).rejects.toThrow(PANE_NOT_LIVE_ERROR) + await expect(attachViewWithoutOwner(providerOf(spawn), { cols: 80, rows: 24 })).rejects.toThrow( + PANE_NOT_LIVE_ERROR + ) + expect(spawn).toHaveBeenCalledTimes(1) + }) +}) + +describe("a view's attach to a pane main started hidden", () => { + beforeEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + markRuntimeOwnedHiddenRendererPty('pty-1') + markHiddenRendererPty('pty-1') + recordHiddenRendererPtyDataDrop('pty-1', 64) + }) + const setHidden = (id: string, hidden: boolean): void => + hidden ? markHiddenRendererPty(id) : void unmarkHiddenRendererPty(id) + const revealAsksRestore = (): boolean => unmarkHiddenRendererPty('pty-1').droppedWhileHidden + + it('paints once: its snapshot holds what main dropped, so the reveal asks no second restore', async () => { + await attachViewAfterHiddenStart('pty-1', setHidden, async () => ({ + id: 'pty-1', + snapshot: 'x' + })) + expect(revealAsksRestore()).toBe(false) + }) + + it('keeps the drops for a restore when the attach carries no snapshot or lands elsewhere', async () => { + await attachViewAfterHiddenStart('pty-1', setHidden, async () => ({ id: 'pty-1' })) + expect(revealAsksRestore()).toBe(true) + await attachViewAfterHiddenStart('pty-1', setHidden, async () => ({ + id: 'pty-2', + snapshot: 'x' + })) + expect(revealAsksRestore()).toBe(true) + }) + + it('keeps a drop made after the view hid again during the attach', async () => { + await attachViewAfterHiddenStart('pty-1', setHidden, async () => { + markHiddenRendererPty('pty-1') + recordHiddenRendererPtyDataDrop('pty-1', 8) + return { id: 'pty-1', snapshot: 'x' } + }) + expect(revealAsksRestore()).toBe(true) + }) + + it('keeps main answering for the pane when the attach fails, and keeps its drops', async () => { + await expect( + attachViewAfterHiddenStart('pty-1', setHidden, () => Promise.reject(new Error('lost'))) + ).rejects.toThrow('lost') + expect(isRuntimeOwnedHiddenRendererPty('pty-1')).toBe(true) + expect(isHiddenRendererPty('pty-1')).toBe(true) + expect(revealAsksRestore()).toBe(true) + }) + + it('does not hide a terminal it never kept visible when its attach fails', async () => { + _resetHiddenRendererPtyDeliveryGateForTest() + await expect( + attachViewAfterHiddenStart('pty-1', setHidden, () => Promise.reject(new Error('lost'))) + ).rejects.toThrow('lost') + expect(isHiddenRendererPty('pty-1')).toBe(false) + }) + + it('keeps main from hiding the terminal until the last attaching view finishes', async () => { + _resetHiddenRendererPtyDeliveryGateForTest() + let finishFirst!: () => void + const first = attachViewAfterHiddenStart( + 'pty-1', + setHidden, + () => new Promise<{ id: string }>((done) => (finishFirst = () => done({ id: 'pty-1' }))) + ) + let finishSecond!: () => void + const second = attachViewAfterHiddenStart( + 'pty-1', + setHidden, + () => new Promise<{ id: string }>((done) => (finishSecond = () => done({ id: 'pty-1' }))) + ) + finishFirst() + await first + expect(attachingRendererViewPtys.has('pty-1')).toBe(true) + finishSecond() + await second + expect(attachingRendererViewPtys.has('pty-1')).toBe(false) + }) + + it('leaves a terminal main did not start hidden as today', async () => { + _resetHiddenRendererPtyDeliveryGateForTest() + markHiddenRendererPty('pty-1') + recordHiddenRendererPtyDataDrop('pty-1', 64) + await attachViewAfterHiddenStart('pty-1', setHidden, async () => ({ + id: 'pty-1', + snapshot: 'x' + })) + expect(isHiddenRendererPty('pty-1')).toBe(true) + expect(revealAsksRestore()).toBe(true) + }) +}) + +describe('attachOnlyViewOf', () => { + it('takes the flag only in a layout-owner build', async () => { + const { ORCA_LAYOUT_OWNER_IS_MAIN } = await import('../../../../shared/layout-owner-switch') + expect(attachOnlyViewOf({ attachOnlyView: true })).toBe( + ORCA_LAYOUT_OWNER_IS_MAIN ? true : undefined + ) + expect(attachOnlyViewOf({ attachOnlyView: 'yes' })).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/pane/attach-only-view.ts b/src/main/ipc/pty/pane/attach-only-view.ts new file mode 100644 index 00000000000..e6c232b4f77 --- /dev/null +++ b/src/main/ipc/pty/pane/attach-only-view.ts @@ -0,0 +1,95 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import { PANE_NOT_LIVE_ERROR } from '../../../../shared/pane-attach-only-view' +import { + consumeHiddenRendererPtyDropMemory, + hiddenRendererPtyDropEpisode, + isRuntimeOwnedHiddenRendererPty, + markRuntimeOwnedHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { attachingRendererViewPtys } from '../delivery/visibility-state' +import type { + IPtyProvider, + PtySpawnOptions, + PtySpawnResult +} from '../../../providers/pty-provider-contract' + +/** + * Layout-owner build: a window view of a pane with no recorded owner attaches to the terminal it + * names, or starts nothing; main starts a pane whose terminal is gone. + */ +export async function attachViewWithoutOwner( + provider: IPtyProvider, + spawnOptions: PtySpawnOptions +): Promise { + if (!spawnOptions.sessionId) { + throw new Error(PANE_NOT_LIVE_ERROR) + } + try { + return await provider.spawn({ + cols: spawnOptions.cols, + rows: spawnOptions.rows, + sessionId: spawnOptions.sessionId, + attachOnly: true + }) + } catch (error) { + throw error instanceof SessionNotFoundError ? new Error(PANE_NOT_LIVE_ERROR) : error + } +} + +/** The spawn's attach-only flag; a build without the switch keeps today's start. */ +export function attachOnlyViewOf(args: { attachOnlyView?: unknown }): true | undefined { + return ORCA_LAYOUT_OWNER_IS_MAIN && args.attachOnlyView === true ? true : undefined +} + +/** + * Layout-owner build: main starts the pane hidden, dropping its output until a view attaches. A + * visible view keeps main's start from hiding it, and unmarks one already hidden before attaching, + * so its attach snapshot holds every dropped byte and the view paints once. + */ +export async function attachViewAfterHiddenStart( + ptyId: string, + setHidden: (id: string, hidden: boolean) => void, + attach: () => Promise +): Promise { + const hiddenByMain = isRuntimeOwnedHiddenRendererPty(ptyId) + const episode = hiddenRendererPtyDropEpisode(ptyId) + if (hiddenByMain) { + setHidden(ptyId, false) + } + const attaching = attachingRendererViewPtys.get(ptyId) ?? { views: 0, skippedHide: false } + attaching.views += 1 + attachingRendererViewPtys.set(ptyId, attaching) + let attached: T + try { + attached = await attach() + } catch (error) { + // Why: until a view attaches, main must keep answering the pane's startup queries. + const keptVisibleByThisAttach = hiddenByMain || attaching.skippedHide + if ( + keptVisibleByThisAttach && + !(error instanceof Error && error.message === PANE_NOT_LIVE_ERROR) + ) { + setHidden(ptyId, true) + markRuntimeOwnedHiddenRendererPty(ptyId) + } + throw error + } finally { + attaching.views -= 1 + if (attaching.views === 0 && attachingRendererViewPtys.get(ptyId) === attaching) { + attachingRendererViewPtys.delete(ptyId) + } + } + // Why a snapshot of the same id: a local attach carries none, and another id's drops may postdate it. + if ( + hiddenByMain && + 'id' in attached && + attached.id === ptyId && + 'snapshot' in attached && + typeof attached.snapshot === 'string' + ) { + // Retires only the episode the snapshot covers; a drop after a re-mark opened a new one. + consumeHiddenRendererPtyDropMemory(ptyId, episode) + } + return attached +} diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts index 256b7ad4f56..55e3a9ce666 100644 --- a/src/main/ipc/pty/pane/spawn-reservation.ts +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -21,8 +21,10 @@ export type PaneSpawnReservationResult = { export const paneSpawnReservationsByOwnerKey = new Map() export type PendingRuntimePaneCreate = { count: number - promise: Promise + promise: Promise resolve: () => void + /** The terminal the create started, for a view that waited on it (layout-owner build). */ + result?: PaneSpawnReservationResult } export const pendingRuntimePaneCreatesByOwnerKey = new Map() @@ -99,13 +101,13 @@ export function claimRuntimePaneCreate(ownerKey: string): () => void { existing.count += 1 return () => releaseRuntimePaneCreate(ownerKey, existing) } - let resolve!: () => void - const claim = { + let resolve!: (result: PaneSpawnReservationResult | undefined) => void + const claim: PendingRuntimePaneCreate = { count: 1, - promise: new Promise((done) => { + promise: new Promise((done) => { resolve = done }), - resolve: () => resolve() + resolve: () => resolve(claim.result) } pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) return () => releaseRuntimePaneCreate(ownerKey, claim) @@ -151,3 +153,14 @@ export function resolvePaneSpawnReservation void /** Only a spawn holding the pane's reservation: it binds next, so its bind swaps the dead id. */ keepAbsentBinding?: (dead: ReplacedPaneOwner) => void + /** Layout-owner build: a window's view, which attaches or starts nothing (attach-only-view.ts). */ + attachOnlyView?: true } export function stablePanePersistenceFence( diff --git a/src/main/ipc/pty/pane/stable-pane-attach.ts b/src/main/ipc/pty/pane/stable-pane-attach.ts index 1d2fd22ff94..a40d200cdee 100644 --- a/src/main/ipc/pty/pane/stable-pane-attach.ts +++ b/src/main/ipc/pty/pane/stable-pane-attach.ts @@ -8,6 +8,7 @@ import { import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { keepAbsentPaneBindingForSwap, releaseAbsentPaneOwner } from './absent-pane-owner' import { excludeReplacedPaneOwner } from './replaced-pane-owner' +import { PANE_NOT_LIVE_ERROR } from '../../../../shared/pane-attach-only-view' import type { StablePaneOwner, StablePaneSpawnContext } from './stable-owner' type StablePaneAttachAttempt = @@ -62,6 +63,10 @@ export async function attemptStablePaneAttach( if (ownerBeforeRetire && !isSameStablePaneOwner(ownerBeforeRetire, owner)) { return { lostTo: ownerBeforeRetire } } + if (args.attachOnlyView) { + // Why the binding stays: main's start reuses its id, which carries the terminal's history. + throw new Error(PANE_NOT_LIVE_ERROR) + } // The adoption holds the pane's reservation, so its fresh bind can swap the dead binding. const kept = args.keepAbsentBinding ? keepAbsentPaneBindingForSwap(args, owner) : null if (kept) { diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.test.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.test.ts new file mode 100644 index 00000000000..9a73ef9f39a --- /dev/null +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { pendingByPaneKey } from './serializer-state' +import { seedHeadlessTerminalFromSpawnResult } from './terminal-spawn-restore' + +const PANE_KEY = 'tab-1:leaf-1' + +describe('seedHeadlessTerminalFromSpawnResult', () => { + afterEach(() => { + pendingByPaneKey.clear() + }) + + it('seeds main’s copy from a cold restore a window has declared, only when main owns the layout', () => { + const seedHeadlessTerminal = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seed calls only seedHeadlessTerminal. + const runtime = { seedHeadlessTerminal } as unknown as OrcaRuntimeService + pendingByPaneKey.set(PANE_KEY, { gen: 1, ownerWebContentsId: 7 }) + + seedHeadlessTerminalFromSpawnResult( + runtime, + { id: 'pty-1', coldRestore: { scrollback: 'history', cwd: '/w' } }, + PANE_KEY + ) + + // Main's copy is what a view repaints from later; the window holds nothing until it paints. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(seedHeadlessTerminal).toHaveBeenCalledWith('pty-1', 'history', undefined, { + cwd: '/w', + oscLinks: undefined, + preferProviderIfExisting: true + }) + } else { + expect(seedHeadlessTerminal).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.ts index 5928425beb7..386a07a931b 100644 --- a/src/main/ipc/pty/pane/terminal-spawn-restore.ts +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.ts @@ -1,16 +1,18 @@ import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' export function seedHeadlessTerminalFromSpawnResult( runtime: OrcaRuntimeService | undefined, result: PtySpawnResult, paneKey: string | null ): void { - // A mounted renderer owns richer history than the provider snapshot. + // A mounted renderer owns richer history than the provider snapshot, unless main owns the + // layout: then the host's history is the only source (a view repaints from main's copy). if ( !runtime || - (paneKey && pendingByPaneKey.has(paneKey)) || + (!ORCA_LAYOUT_OWNER_IS_MAIN && paneKey && pendingByPaneKey.has(paneKey)) || (result.isReattach === true && rendererSerializerReadiness.has(result.id)) ) { return diff --git a/src/main/ipc/pty/runtime/spawn-commit-response.ts b/src/main/ipc/pty/runtime/spawn-commit-response.ts index eb8dff71f60..fd94930fe2a 100644 --- a/src/main/ipc/pty/runtime/spawn-commit-response.ts +++ b/src/main/ipc/pty/runtime/spawn-commit-response.ts @@ -1,6 +1,7 @@ import { startupCwdFallbackOutcome } from '../spawn-startup-target' import type { RuntimePtySpawnState } from './spawn-state' -import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { noteRuntimePaneCreateResult, resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' export function completeRuntimeSpawnResponse(ctx: RuntimePtySpawnState) { const args = ctx.args @@ -23,14 +24,19 @@ export function completeRuntimeSpawnResponse(ctx: RuntimePtySpawnState) { ...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}), ...(startupCwdFallback ? { startupCwdFallback } : {}) } - resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + const joined = { ...ctx.result, ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && ctx.reconciledSnapshotSeq !== null && ctx.snapshotKittyFlagsCoverReconciledSeq ? { snapshotSeq: ctx.reconciledSnapshotSeq } : { snapshotKittyKeyboardFlags: undefined }), - isReattach: true - }) + isReattach: true as const + } + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, joined) + // Switch point: a window view waiting on this create attaches to what it started. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + noteRuntimePaneCreateResult(ctx.paneSpawnReservationKey, joined) + } return { ...ctx.result, ...response, wslDistro: ctx.result.wslDistro ?? undefined } } diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 773215d10de..a67d52fd2af 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -1,5 +1,5 @@ import { markRuntimeOwnedHiddenRendererPty } from '../../pty-hidden-delivery-gate' -import { visibleRendererPtys } from '../delivery/visibility-state' +import { attachingRendererViewPtys, visibleRendererPtys } from '../delivery/visibility-state' import { closeStartupQueryAuthorityForPty } from '../provider/registry' import type { RuntimePtySpawnState } from './spawn-state' @@ -17,6 +17,11 @@ export function markRuntimeSpawnHiddenBeforeSpawn(ctx: RuntimePtySpawnState): vo ) { return } + const attaching = attachingRendererViewPtys.get(ctx.effectiveSessionAppId) + if (attaching) { + attaching.skippedHide = true + return + } ctx.preSpawnHiddenMarkId = ctx.effectiveSessionAppId markRuntimeSpawnHidden(ctx, ctx.preSpawnHiddenMarkId) } @@ -35,8 +40,13 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi releaseRuntimeSpawnPreSpawnHiddenMark(ctx) return } - // Why: a view that mounted visible during spawn already unmarked; re-marking would drop its output. - if (visibleRendererPtys.has(id)) { + // Why: a view that mounted visible during spawn already unmarked, or is attaching to it now; + // marking would drop its output and ask it for a second restore. + const attaching = attachingRendererViewPtys.get(id) + if (attaching) { + attaching.skippedHide = true + } + if (visibleRendererPtys.has(id) || attaching) { releaseRuntimeSpawnPreSpawnHiddenMark(ctx) ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn') return diff --git a/src/main/persistence/terminal-topology/layout-command-commit.ts b/src/main/persistence/terminal-topology/layout-command-commit.ts index ee65bb734ff..92a1791a14a 100644 --- a/src/main/persistence/terminal-topology/layout-command-commit.ts +++ b/src/main/persistence/terminal-topology/layout-command-commit.ts @@ -5,7 +5,8 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { LayoutApplyOutcome, - LayoutCommand + LayoutCommand, + LayoutEffects } from '../../../shared/workspace-layout/workspace-layout-command-types' import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' import { @@ -13,13 +14,25 @@ import { partitionEditorViewsOf } from '../loading-store/layout-editor-views' import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze' -import { applyCommandToSession, type PaneLayoutChange } from './terminal-pane-admission' +import { + applyCommandToSession, + type LayoutLiveContext, + type PaneLayoutChange +} from './terminal-pane-admission' import { writeMovedLeafProfileRecords } from './terminal-leaf-move' import { writeRestorable } from './restorable-write' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' import { resolveWindowPaneDetach, type WindowPaneDetachCommand } from './window-pane-detach' import type { TerminalTopologyCommitContext } from './terminal-topology-commit' +/** The runtime's side of a layout command: live facts in, the routed partition's effects out. */ +export type LayoutCommandRun = { + live?: LayoutLiveContext + /** Stops the terminals the command replaces, before its write; false keeps their bindings. */ + stopBeforeWrite?: (ptyIds: readonly string[]) => boolean + noteEffects?: (effects: LayoutEffects) => void +} + // A paused WSL move keeps its local rows as the quiesce left them. export function keepFrozen( hostId: ExecutionHostId, @@ -71,13 +84,23 @@ export function commitWindowPaneDetach( export function commitLayoutCommand( hostIds: readonly ExecutionHostId[], command: LayoutCommand, - context: TerminalTopologyCommitContext + context: TerminalTopologyCommitContext, + run: LayoutCommandRun = {} ): DurableProfileStateMutation { - const changes = hostIds.map((hostId) => changeIn(hostId, command, context)) + const changes = hostIds.map((hostId) => changeIn(hostId, command, context, run.live)) const routed = changes[0]?.change.value if (!routed) { return { value: { ok: false, code: 'workspace_not_found' }, persist: false } } + const effects = routed.ok ? changes[0]!.change.effects : undefined + // Why before the write: a terminal that would not stop keeps its pane. A write that then fails + // restores the binding to the stopped terminal, which the pane's next start replaces. + if (effects?.stopPtyIds.length && run.stopBeforeWrite?.(effects.stopPtyIds) === false) { + return { value: { ok: false, code: 'pane_already_bound' }, persist: false } + } + if (effects) { + run.noteEffects?.(effects) + } return written(routed, routed.ok ? changes.filter(({ change }) => change.value.ok) : [], context) } @@ -100,7 +123,8 @@ type PartitionChange = { hostId: ExecutionHostId; change: PaneLayoutChange boolean + onEffects?: (effects: LayoutEffects) => void + } = {} ): Promise { const context = this[layoutCommandPersistenceContext] const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()] + let effects: LayoutEffects | undefined const commit = raisingBindingGenerations( context.runtime, resolved, (hostId) => context.sessions.getWorkspaceSession(hostId), - applyLayout(resolved, command, topologyCommitContext(context)) + applyLayout(resolved, command, topologyCommitContext(context), { + live: run.live, + stopBeforeWrite: run.stopBeforeWrite, + noteEffects: (noted) => (effects = noted) + }) ) // Switch point: a terminal close stands once applied, as the switch-off window shows it. - if (ORCA_LAYOUT_OWNER_IS_MAIN && context.writeLater && isTerminalClose(command)) { - return keptOnWriteFailure( - (mutate) => context.runtime.runDurableMutation(mutate), - writingOnly(resolved, commit), - context.writeLater - ) + const applied: Promise = + ORCA_LAYOUT_OWNER_IS_MAIN && context.writeLater && isTerminalClose(command) + ? keptOnWriteFailure( + (mutate) => context.runtime.runDurableMutation(mutate), + writingOnly(resolved, commit), + context.writeLater + ) + : context.runtime.runDurableMutation(writingOnly(resolved, commit)) + if (!run.onEffects) { + return applied } - return context.runtime.runDurableMutation(writingOnly(resolved, commit)) + return applied.then((outcome) => { + if (outcome.ok && effects) { + run.onEffects?.(effects) + } + return outcome + }) } /** One disk move's editor renames, a command per partition: all written in one durable write or, diff --git a/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts b/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts index a13dd0f93e9..1a8ae038586 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-sleep-wake.test.ts @@ -93,7 +93,7 @@ describe('Store.applyLayoutCommand sleep and wake (layout.apply)', () => { }) }) - it('wakes only the named pane; the other sleeping pane keeps its record', async () => { + it('a record-only wake drops only the named pane’s record', async () => { const { store, directory } = await storeWithSplitTab() const left = record(LEFT, 'left-session') await store.applyLayoutCommand({ @@ -106,7 +106,8 @@ describe('Store.applyLayoutCommand sleep and wake (layout.apply)', () => { const reply = await store.applyLayoutCommand({ type: 'wake', workspace: WORKTREE, - paneKeys: [left.paneKey] + paneKeys: [left.paneKey], + recordOnly: true }) expect(reply).toEqual({ ok: true, result: { woken: [left.paneKey] } }) @@ -116,6 +117,28 @@ describe('Store.applyLayoutCommand sleep and wake (layout.apply)', () => { }) }) + it('a wake starts the pane and keeps its record, which the start resumes from', async () => { + const { store } = await storeWithSplitTab() + const left = record(LEFT, 'left-session') + await store.applyLayoutCommand({ + type: 'sleep', + workspace: WORKTREE, + paneKeys: [left.paneKey], + records: [left] + }) + const onEffects = vi.fn() + + const reply = await store.applyLayoutCommand( + { type: 'wake', workspace: WORKTREE, paneKeys: [left.paneKey] }, + [], + { onEffects } + ) + + expect(reply).toEqual({ ok: true, result: { woken: [left.paneKey] } }) + expect(onEffects).toHaveBeenCalledWith({ stopPtyIds: [], startPaneKeys: [left.paneKey] }) + expect(store.getWorkspaceSession().sleepingAgentSessionsByPaneKey?.[left.paneKey]).toEqual(left) + }) + it('wakes nothing for a pane that holds no record', async () => { const { store } = await storeWithSplitTab() const before = store.getWorkspaceSession() @@ -145,3 +168,45 @@ describe('Store.applyLayoutCommand sleep and wake (layout.apply)', () => { expect(store.getWorkspaceSession().sleepingAgentSessionsByPaneKey).toBeUndefined() }) }) + +describe('Store.applyLayoutCommand restartPane (layout.apply)', () => { + const restart = { type: 'restartPane', workspace: WORKTREE, paneKey: `${TAB}:${LEFT}` } as const + + it('stops the pane’s terminal before it writes the pane unbound, then starts it', async () => { + const { store, directory } = await storeWithSplitTab() + const order: string[] = [] + const stopBeforeWrite = vi.fn(() => { + order.push( + `stop, bound to ${store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId?.[LEFT]}` + ) + return true + }) + const onEffects = vi.fn(() => order.push('start')) + + const reply = await store.applyLayoutCommand(restart, [], { stopBeforeWrite, onEffects }) + + expect(reply).toMatchObject({ ok: true }) + expect(stopBeforeWrite).toHaveBeenCalledWith(['pty-left']) + expect(order).toEqual(['stop, bound to pty-left', 'start']) + const session = (await reopenTopologyStore(store, directory)).getWorkspaceSession() + expect(session.terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId?.[LEFT]).toBeUndefined() + }) + + it('keeps the pane bound and starts nothing when its terminal would not stop', async () => { + const { store, directory } = await storeWithSplitTab() + const onEffects = vi.fn() + + const reply = await store.applyLayoutCommand(restart, [], { + stopBeforeWrite: () => false, + onEffects + }) + + expect(reply).toEqual({ ok: false, code: 'pane_already_bound' }) + expect(onEffects).not.toHaveBeenCalled() + const session = (await reopenTopologyStore(store, directory)).getWorkspaceSession() + expect(session.terminalLayoutsByTabId[TAB]?.ptyIdsByLeafId).toEqual({ + [LEFT]: 'pty-left', + [RIGHT]: 'pty-right' + }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts index a012e492b1a..61e8195bfe7 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-admission.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -12,6 +12,8 @@ import type { CommandOf, LayoutApplyOutcome, LayoutCommand, + LayoutContext, + LayoutEffects, LayoutRefusalCode } from '../../../shared/workspace-layout/workspace-layout-command-types' import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' @@ -45,8 +47,13 @@ export type PaneLayoutChange = { value: T session: WorkspaceSessionState | null views?: PartitionEditorViews + /** What the runtime runs once the change is written; never part of a reply. */ + effects?: LayoutEffects } +/** The runtime's live facts a command reads; the stored layout cannot know them. */ +export type LayoutLiveContext = Pick + export function admitPaneToSession( hostId: ExecutionHostId, session: WorkspaceSessionState, @@ -58,7 +65,7 @@ export function admitPaneToSession( if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) { return { value: 'exists', session: null } } - const change = applyToSession(loaded, admission, false, touchedRecordsWrite(session, loaded)) + const change = applyToSession(loaded, admission, false, {}, touchedRecordsWrite(session, loaded)) return { ...change, value: change.value.ok ? 'admitted' : change.value.code } } @@ -68,7 +75,7 @@ export function withdrawPaneFromSession( pane: CommandOf<'closePane'> ): PaneLayoutChange { const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) - const change = applyToSession(loaded, pane, false, touchedRecordsWrite(session, loaded)) + const change = applyToSession(loaded, pane, false, {}, touchedRecordsWrite(session, loaded)) return { ...change, value: change.value.ok ? null : change.value.code } } @@ -100,7 +107,8 @@ export function applyCommandToSession( session: WorkspaceSessionState, command: LayoutCommand, /** Main's in-memory editor views, which the saved form lacks (layout-editor-views). */ - views?: PartitionEditorViews + views?: PartitionEditorViews, + live: LayoutLiveContext = {} ): PaneLayoutChange { const context = views ? { ...nameBasedLoadContext(), editorViewsOf: (key: string) => views[key] } @@ -110,7 +118,7 @@ export function applyCommandToSession( if (taken) { return { value: taken, session: null } } - return applyToSession(loaded, command, views !== undefined, (after) => { + return applyToSession(loaded, command, views !== undefined, live, (after) => { const patched = touchedRecords(session, loaded, after) return patched ? followRemoteSessionAfterPaneDetach( @@ -154,6 +162,7 @@ function applyToSession( loaded: LoadedWorkspaceLayout, command: LayoutCommand, keepsViews: boolean, + live: LayoutLiveContext, write: (after: WorkspaceSessionState) => WorkspaceSessionState | null = (after) => after ): PaneLayoutChange { const closed = closedIds(loaded.layout, command) @@ -161,6 +170,7 @@ function applyToSession( return { value: closed, session: null } } const applied = applyLayoutCommand(loaded.layout, command, { + ...live, mintId: randomUUID, mintLeafId: randomUUID, now: Date.now @@ -169,6 +179,7 @@ function applyToSession( ? { value: { ok: true, result: applied.result }, session: write(saveWorkspaceLayout({ ...loaded, layout: applied.model })), + effects: applied.effects, ...(keepsViews ? { views: editorViewsOfModel(applied.model) } : {}) } : { value: { ok: false, code: applied.code }, session: null } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 60f0acc663c..4945d630138 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -35,7 +35,8 @@ import { commitLayoutCommand, commitLayoutCommandsAcrossPartitions, commitWindowPaneDetach, - keepFrozen + keepFrozen, + type LayoutCommandRun } from './layout-command-commit' import { fencedIfLayoutOwner, @@ -136,7 +137,8 @@ export function withdrawPane( export function applyLayout( hostIds: readonly ExecutionHostId[], command: LayoutCommand, - context: TerminalTopologyCommitContext + context: TerminalTopologyCommitContext, + run: LayoutCommandRun = {} ): () => DurableProfileStateMutation { return traced( 'apply_layout', @@ -150,7 +152,7 @@ export function applyLayout( { ...command, windowDetach: command.windowDetach }, context ) - : commitLayoutCommand(hostIds, command, context) + : commitLayoutCommand(hostIds, command, context, run) ), (outcome) => (outcome.ok ? undefined : outcome.code) ) diff --git a/src/main/runtime/layout-command-pane-starts-layout-owner.test.ts b/src/main/runtime/layout-command-pane-starts-layout-owner.test.ts new file mode 100644 index 00000000000..2b7890eb628 --- /dev/null +++ b/src/main/runtime/layout-command-pane-starts-layout-owner.test.ts @@ -0,0 +1,366 @@ +// Layout-owner build: a client's startPane and wake start the pane through main's one starter +// after the command is written. The reply never carries the effects, a wake keeps the pane's +// resume record in the pane-owned form, and a failed start shows on the pane and keeps the record. +import { describe, expect, it, vi } from 'vitest' +import { + isPaneSleepRecord, + type SleepingAgentSessionRecord as SleepingRecord +} from '../../shared/agent-session-resume' +import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' +import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-session-contracts' +import type { RestoredPaneTrigger } from './terminal/recovery/restored-pane-start' + +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. +const { OrcaRuntimeService, electronMocks } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { + HEADLESS_LEAF_ID, + TEST_WINDOW_ID, + TEST_WORKTREE_ID, + deferred, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} = await import('./orca-runtime-test-fixtures.spec') + +const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` +const PANE = { worktreeId: TEST_WORKTREE_ID, tabId: 'host-tab', leafId: HEADLESS_LEAF_ID } + +class StatusRuntime extends OrcaRuntimeService { + /** The start's terminal is gone once its create returns, as one that exits before main can bind it. */ + startsNoTerminal = false + protected override materializePendingMobileSessionTerminal( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab, + trigger: RestoredPaneTrigger & { sessionId?: string } + ): Promise { + return super + .materializePendingMobileSessionTerminal(worktreeId, tab, trigger) + .then((created) => { + if (this.startsNoTerminal && created.tab.ptyId) { + this.ptysById.delete(created.tab.ptyId) + } + return created + }) + } + setStartStatus(paneKey: string, status: 'sleeping'): void { + this.paneStartStatuses?.set(paneKey, { status }) + } + startStatusOf(paneKey: string) { + return this.paneStartStatuses?.peek(paneKey) ?? null + } + loseContactWith(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.connected = false + } + this.markPtyLivenessUnverifiable(ptyId, 'host out of contact') + } +} + +// A running agent's workspace sleep, which the window's wake relaunched before C1c. +const RECORD: SleepingRecord = { + paneKey: PANE_KEY, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + prompt: 'do the thing', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' +} + +async function desktopRuntime( + opts: { + slept?: boolean + session?: ReturnType + /** False: the window's graph has not listed the workspace's tabs yet, as at a relaunch. */ + activate?: boolean + } = {} +) { + const session = + opts.session ?? + makeWorkspaceSessionWithHeadlessTerminal( + opts.slept ? { sleepingAgentSessionsByPaneKey: { [PANE_KEY]: RECORD } } : {} + ) + // The tab was launched as the agent its record resumes. + session.tabsByWorktree[TEST_WORKTREE_ID]![0]!.launchAgent = 'claude' + const fixture = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store implements the runtime store methods these tests reach. + const runtime = new StatusRuntime(fixture.runtimeStore as never, undefined, { + getDesktopWindowStatus: () => 'openable' + }) + const started = deferred<{ id: string }>() + const spawn = vi.fn((_args: { command?: string; sessionId?: string }) => started.promise) + const kill = vi.fn(() => true) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + // The desktop window is open: before C1c it started this pane itself. + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only isDestroyed from the window mock. + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + if (opts.activate !== false) { + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + } + return { + runtime, + spawn, + kill, + started, + getSession: fixture.getSession, + setSession: fixture.setSession + } +} + +const startPane = { type: 'startPane', workspace: TEST_WORKTREE_ID, tabId: 'host-tab' } as const +const wake = { type: 'wake', workspace: TEST_WORKTREE_ID, tabId: 'host-tab' } as const + +describe('a client’s startPane in the layout-owner build', () => { + it('starts a pane restoring from its terminal once, through main, and replies without effects', async () => { + const { runtime, spawn, started } = await desktopRuntime() + runtime.setStartStatus(PANE_KEY, 'sleeping') + + const reply = await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + // The command's start is under way before any other trigger; a CLI send joins it. + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + const cliSend = runtime.ensureRestoredPaneStarted(PANE) + started.resolve({ id: 'persisted-pty' }) + + await expect(cliSend).resolves.toMatchObject({ id: 'persisted-pty' }) + expect(spawn).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'persisted-pty' })) + // The wire reply is the command's outcome and stream position, byte for byte. + expect(JSON.stringify(reply)).toBe( + JSON.stringify({ ok: true, result: {}, frameSeq: reply.frameSeq }) + ) + }) + + it('leaves a pane whose terminal is live alone', async () => { + const { runtime, spawn, started } = await desktopRuntime() + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + started.resolve({ id: 'persisted-pty' }) + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + await vi.waitFor(() => expect(runtime.startStatusOf(PANE_KEY)).toBeNull()) + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('leaves a pane alone while its terminal is out of contact', async () => { + const { runtime, spawn, started } = await desktopRuntime() + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + started.resolve({ id: 'persisted-pty' }) + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + await vi.waitFor(() => expect(runtime.startStatusOf(PANE_KEY)).toBeNull()) + + // Lost contact is never evidence the terminal ended. + runtime.loseContactWith('persisted-pty') + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('starts a pane the window views before its graph lists the tab', async () => { + const { runtime, spawn, started } = await desktopRuntime({ activate: false }) + runtime.setStartStatus(PANE_KEY, 'sleeping') + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + started.resolve({ id: 'persisted-pty' }) + + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + expect(runtime.startStatusOf(PANE_KEY)).not.toMatchObject({ status: 'failed' }) + }) + + it('replaces the empty shell a hibernated agent left and resumes the agent', async () => { + const { runtime, spawn, kill, started, getSession, setSession } = await desktopRuntime() + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + started.resolve({ id: 'persisted-pty' }) + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + await vi.waitFor(() => expect(runtime.startStatusOf(PANE_KEY)).toBeNull()) + // The agent hibernated: its record says done, and its shell has printed nothing since. + setSession({ + ...getSession(), + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { ...RECORD, state: 'done' } } + }) + spawn.mockClear() + spawn.mockResolvedValue({ id: 'resumed-pty' }) + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + expect(kill).toHaveBeenCalledWith('persisted-pty') + expect(spawn.mock.calls[0]?.[0].command).toContain('provider-session-1') + }) + + it('keeps the pane on its terminal and starts nothing when that terminal would not stop', async () => { + const { runtime, spawn, kill, started, getSession, setSession } = await desktopRuntime() + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + started.resolve({ id: 'persisted-pty' }) + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + await vi.waitFor(() => expect(runtime.startStatusOf(PANE_KEY)).toBeNull()) + setSession({ + ...getSession(), + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: { ...RECORD, state: 'done' } } + }) + spawn.mockClear() + kill.mockReturnValue(false) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const reply = await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(kill).toHaveBeenCalledWith('persisted-pty') + expect(reply).toMatchObject({ ok: false }) + expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true) + expect(spawn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('resumes the agent a pre-stable-id build recorded under the pane’s number', async () => { + const legacyKey = 'host-tab:2' + const { runtime, spawn, started } = await desktopRuntime({ + session: makeWorkspaceSessionWithHeadlessTerminal({ + sleepingAgentSessionsByPaneKey: { + [legacyKey]: { ...RECORD, paneKey: legacyKey }, + // A second alias of the same conversation is the same record, not another pane's. + 'host-tab:3': { ...RECORD, paneKey: 'host-tab:3', capturedAt: 2 } + } + }) + }) + runtime.setStartStatus(PANE_KEY, 'sleeping') + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + started.resolve({ id: 'persisted-pty' }) + + expect(spawn.mock.calls[0]?.[0].command).toContain('provider-session-1') + }) + + it('shows the pane starting in the frame its reply names', async () => { + const { runtime } = await desktopRuntime() + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + expect(runtime.startStatusOf(PANE_KEY)).toEqual({ status: 'starting' }) + }) + + it('shows a failed start on the pane', async () => { + const { runtime, started } = await desktopRuntime() + runtime.setStartStatus(PANE_KEY, 'sleeping') + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + started.reject(new Error('spawn exploded')) + + await vi.waitFor(() => + expect(runtime.startStatusOf(PANE_KEY)).toMatchObject({ status: 'failed' }) + ) + }) + // The window shows this reason through its wait for main's start. + it('shows terminal_not_started when the terminal ends before main can bind it', async () => { + const { runtime, started } = await desktopRuntime() + runtime.setStartStatus(PANE_KEY, 'sleeping') + runtime.startsNoTerminal = true + + await runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + started.resolve({ id: 'persisted-pty' }) + + await vi.waitFor(() => + expect(runtime.startStatusOf(PANE_KEY)).toEqual({ + status: 'failed', + reason: 'terminal_not_started' + }) + ) + }) +}) + +describe('a client’s wake of a slept pane in the layout-owner build', () => { + it('resumes its agent through main and keeps the record pane-owned, so a host crash resumes again', async () => { + const { runtime, spawn, started, getSession } = await desktopRuntime({ slept: true }) + + const reply = await runtime.applyLayoutCommand({ ...wake, paneKeys: [PANE_KEY] }) + expect(reply).toMatchObject({ ok: true, result: { woken: [PANE_KEY] } }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + const command = spawn.mock.calls[0]![0].command ?? '' + expect(command).toContain('--resume') + expect(command).toContain('provider-session-1') + started.resolve({ id: 'persisted-pty' }) + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toMatchObject({ + state: 'done', + restoreOnTabOpenOnly: true + }) + + // The host crashes: its terminal is gone, and a new runtime loads the same profile. + const crashed = getSession() + const restarted = await desktopRuntime({ session: crashed }) + restarted.runtime.setStartStatus(PANE_KEY, 'sleeping') + await restarted.runtime.applyLayoutCommand({ ...startPane, paneKey: PANE_KEY }) + await vi.waitFor(() => expect(restarted.spawn).toHaveBeenCalled()) + expect(restarted.spawn.mock.calls[0]![0].command ?? '').toContain('provider-session-1') + }) + + it('a woken running pane reads live, not sleeping: its record becomes a running agent’s', async () => { + const { runtime, spawn, started, getSession } = await desktopRuntime({ slept: true }) + + await runtime.applyLayoutCommand({ ...wake, paneKeys: [PANE_KEY] }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + started.resolve({ id: 'persisted-pty' }) + await vi.waitFor(() => + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.origin).toBe('live') + ) + const record = getSession().sleepingAgentSessionsByPaneKey![PANE_KEY]! + // Not a sleep record, so main's layout holds it as the pane's resume record (L254). + expect(isPaneSleepRecord(record)).toBe(false) + expect(record.providerSession).toEqual(RECORD.providerSession) + expect(runtime.startStatusOf(PANE_KEY)).toBeNull() + }) + + it('keeps the record when the start fails', async () => { + const { runtime, spawn, started, getSession } = await desktopRuntime({ slept: true }) + + await runtime.applyLayoutCommand({ ...wake, paneKeys: [PANE_KEY] }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + started.reject(new Error('spawn exploded')) + + await vi.waitFor(() => + expect(runtime.startStatusOf(PANE_KEY)).toMatchObject({ status: 'failed' }) + ) + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined() + }) + + it('starts once for a second wake while the first is starting', async () => { + const { runtime, spawn, started } = await desktopRuntime({ slept: true }) + + await runtime.applyLayoutCommand({ ...wake, paneKeys: [PANE_KEY] }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + await runtime.applyLayoutCommand({ ...wake, paneKeys: [PANE_KEY] }) + started.resolve({ id: 'persisted-pty' }) + + await vi.waitFor(() => expect(runtime.isRestoredPaneBoundTo(PANE, 'persisted-pty')).toBe(true)) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('starts nothing for a record-only wake', async () => { + const { runtime, spawn, getSession } = await desktopRuntime({ slept: true }) + + await runtime.applyLayoutCommand({ ...wake, paneKeys: [PANE_KEY], recordOnly: true }) + await Promise.resolve() + + expect(spawn).not.toHaveBeenCalled() + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index f6511cb98e5..2fec0f6c209 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -593,15 +593,33 @@ function makeRuntimeStoreWithWorkspaceSession( hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(), setWorkspaceSession: vi.fn(setSession), // Main's `layout.apply` on this partition, as the Store applies it (switch-on writes). - applyLayoutCommand: vi.fn(async (command: LayoutCommand, hostIds?: readonly string[]) => { - const change = applyCommandToSession( - resolveHostId(hostIds?.[0] ?? ownerHostId), - session, - command - ) - session = change.session ?? session - return change.value - }), + applyLayoutCommand: vi.fn( + async ( + command: LayoutCommand, + hostIds?: readonly string[], + run: NonNullable[2]> = {} + ) => { + const change = applyCommandToSession( + resolveHostId(hostIds?.[0] ?? ownerHostId), + session, + command, + undefined, + run.live + ) + if ( + change.value.ok && + change.effects?.stopPtyIds.length && + run.stopBeforeWrite?.(change.effects.stopPtyIds) === false + ) { + return { ok: false, code: 'pane_already_bound' } as const + } + session = change.session ?? session + if (change.value.ok && change.effects) { + run.onEffects?.(change.effects) + } + return change.value + } + ), // Headless close is a durable transaction; keep the in-memory fixture's // persistence contract equivalent to the production store. flushOrThrow: vi.fn(), diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts index 40d2f20114c..7e2ba039bed 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec.ts @@ -1,3 +1,4 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import type { SleepingAgentSessionRecord } from '../orca-runtime-test-mocks.spec' @@ -158,8 +159,12 @@ describe('opening a workspace-slept running agent on a host with no window', () expect(command).toMatch(/--permission-mode'? '?plan/) expect(command).not.toContain('--dangerously-skip-permissions') // Why: a later window wake relaunches a running record (a second writer), never a pane-owned one. - expect(fixture.getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toMatchObject( - PANE_OWNED + // Layout-owner build: the resumed agent runs, so main marks its record a running agent's (L254). + await vi.waitFor(() => + expect(fixture.getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toMatchObject({ + ...PANE_OWNED, + ...(ORCA_LAYOUT_OWNER_IS_MAIN ? { origin: 'live' } : {}) + }) ) }) @@ -248,14 +253,19 @@ describe('opening a workspace-slept running agent on a host with no window', () expect(spawnedCommand(retried.spawn)).toContain('provider-session-1') }) - it('opens a plain shell, not a fresh agent, when a window will wake the record', async () => { + it('with a window, opens a plain shell for the window to wake, or resumes it itself under main', async () => { const fixture = makeWorkingSleepStore() withClaudeLaunchedTab(fixture) const { opened, spawn } = openPane(fixture.runtimeStore, 'openable', { withWindow: true }) await opened expect(spawn).toHaveBeenCalledOnce() - expect(spawnedCommand(spawn)).toBe('') + // Layout-owner build: main owns every wake, so the window never resumes it a second time. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(spawnedCommand(spawn)).toContain('provider-session-1') + } else { + expect(spawnedCommand(spawn)).toBe('') + } expect(fixture.getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined() }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3a6be414d02..c44d3f8e351 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -282,11 +282,11 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { ? this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) : [this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ?? []].flat(), command, - (move) => rekeyMovedPaneOwners(this, move) + (move) => rekeyMovedPaneOwners(this, move), + this.layoutCommandRun(command) ) // A window's sleep or wake lands after the hook statuses main accepted before it, as in the window. - const reply = await (ORCA_LAYOUT_OWNER_IS_MAIN && - (command.type === 'sleep' || command.type === 'wake') + const reply = await (ORCA_LAYOUT_OWNER_IS_MAIN && ['sleep', 'wake'].includes(command.type) ? this.inResumeRecordOrder(apply) : (placing?.then(apply) ?? apply())) // Sent before the reply, so the window can wait for the frame that shows the change. diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index f1221770653..6894a8f5513 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -32,6 +32,10 @@ export type WindowLayoutCommand = | { type: 'setDefaultTabsApplied' | 'applyDefaultTabs' } )) +export type LayoutCommandRunOptions = NonNullable< + Parameters>[2] +> + /** * `layout.apply`: the window's command on the partitions holding its tab, routed partition first; * a group tree command goes to the workspace's routed partition. A pane close is the user's: main @@ -43,7 +47,9 @@ export async function applyWindowLayoutCommand( hostIdsFor: (worktreeId: string, tabId: string | null) => ExecutionHostId[], command: WindowLayoutCommand, /** Agent status and orchestration records follow a pane moved into a new tab. */ - onPaneMoved: (move: TerminalLeafMoveRequest) => void = () => {} + onPaneMoved: (move: TerminalLeafMoveRequest) => void = () => {}, + /** Layout-owner build: main's live facts in, the command's effects out (never in the reply). */ + run?: LayoutCommandRunOptions ): Promise { if (command.type === 'renameTabs') { return renameTabs(store, hostIdsFor, command) @@ -133,12 +139,12 @@ export async function applyWindowLayoutCommand( } return reply } - // A pane's sleeping record lives with the pane, in each partition that holds its tab. - if (command.type === 'sleep' || command.type === 'wake') { - return store.applyLayoutCommand(command, holders) - } if (command.type !== 'closePane') { - return store.applyLayoutCommand(command, [hostIds[0]]) + // A pane's sleeping record lives with the pane, in each partition that holds its tab. + const target = command.type === 'sleep' || command.type === 'wake' ? holders : [hostIds[0]] + return run + ? store.applyLayoutCommand(command, target, run) + : store.applyLayoutCommand(command, target) } const { workspace, tabId, leafId } = command // A launch still starting in the pane the user closed stops, and says so (as the legacy close). diff --git a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts index 9c4fff48e71..7abda93a866 100644 --- a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts @@ -1,3 +1,4 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as MobileSessionPublication } from '../../session-tabs/session-tabs-change-publication' import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as MobileSessionPreservation } from '../../session-tabs/mobile-session-publication-preservation' import { OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh } from './perform-mobile-session-pty-records-refresh' @@ -13,7 +14,6 @@ import { deriveClientSessionTabSelection, projectClientSessionTabSelection } from '../../client-session-tab-selection-projection' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { agentProviderSessionsEqual, type SleepingAgentSessionRecord @@ -28,7 +28,7 @@ import { buildHeadlessMobileSessionTabGroups, cloneTerminalLayoutSnapshot } from '../../mobile-session-layout-projection' -import { worktreeIdsEqual } from '../../../../shared/worktree/id' +import { findPaneSleepingRecord } from './pane-sleeping-record' export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh { protected abstract emitMobileSessionTabsSnapshotToClient( @@ -153,11 +153,14 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc // Why: an originless record predates capture origins and carries no proof of which pane or // host wrote it, so it is left (record kept) for the window's legacy cleanup (#29303). // A host with no window right now has no window wake, so the pane owns the record and claims it. + // Switch point: a layout-owner window never wakes a pane itself, so main owns every wake. const paneOwned = record.origin === 'live' || record.origin === 'quit' || (record.origin === 'worktree-sleep' && - (record.state === 'done' || !this.getAvailableAuthoritativeWindow())) + (record.state === 'done' || + ORCA_LAYOUT_OWNER_IS_MAIN || + !this.getAvailableAuthoritativeWindow())) return paneOwned ? record : null } @@ -178,9 +181,11 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc // Why: nothing here spends the record, so a session another pane also records could be // resumed twice (two writers on one transcript), as could one already live in another pane. const records = this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey - const sharedWithAnotherPane = Object.values(records ?? {}).some( - (other) => + const aliases = this.findPaneSleepingAgentRecord(worktreeId, tab)?.aliases ?? [] + const sharedWithAnotherPane = Object.entries(records ?? {}).some( + ([paneKey, other]) => other !== record && + !aliases.includes(paneKey) && other.agent === record.agent && agentProviderSessionsEqual(record.agent, other.providerSession, record.providerSession) ) @@ -205,7 +210,8 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc const claimed = await claimWindowWakeRecordForPane( (edit) => this.commitWorkspaceSessionEdit(hostId, edit), // Why: a window that attached during the awaits above now owns this record's wake. - () => Boolean(this.getAvailableAuthoritativeWindow()), + // Switch point: a layout-owner window never starts a terminal, so main owns every wake. + () => !ORCA_LAYOUT_OWNER_IS_MAIN && Boolean(this.getAvailableAuthoritativeWindow()), record ) return claimed ? resume : {} @@ -258,11 +264,11 @@ export abstract class OrcaRuntimeWithApplyMobileSessionTabNavigation extends Orc worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): SleepingAgentSessionRecord | null { - const record = - this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ - makePaneKey(tab.parentTabId, tab.leafId) - ] - return record && worktreeIdsEqual(record.worktreeId, worktreeId) ? record : null + return this.findPaneSleepingAgentRecord(worktreeId, tab)?.record ?? null + } + + private findPaneSleepingAgentRecord(worktreeId: string, tab: RuntimeMobileSessionTerminalTab) { + return findPaneSleepingRecord(this.getWorkspaceSessionForWorktree(worktreeId), worktreeId, tab) } protected shouldMaterializeHeadlessMobileSessionTab( diff --git a/src/main/runtime/session-lifecycle/projection/pane-sleeping-record.test.ts b/src/main/runtime/session-lifecycle/projection/pane-sleeping-record.test.ts new file mode 100644 index 00000000000..278d0dc396d --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/pane-sleeping-record.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { findPaneSleepingRecord } from './pane-sleeping-record' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const WORKTREE = 'repo::/work' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const RECORD: SleepingAgentSessionRecord = { + paneKey: 'tab-1:2', + tabId: 'tab-1', + worktreeId: WORKTREE, + agent: 'claude', + providerSession: { key: 'session_id', id: 'conv-1' }, + prompt: 'finish', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' +} + +function session( + root: unknown, + records: Record = { 'tab-1:2': RECORD } +): WorkspaceSessionState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lookup reads only the records and the tab's layout. + return { + sleepingAgentSessionsByPaneKey: records, + terminalLayoutsByTabId: { 'tab-1': { root, activeLeafId: LEFT, expandedLeafId: null } } + } as unknown as WorkspaceSessionState +} + +describe("main's lookup of a pane's numbered sleeping record", () => { + it("takes the tab's one conversation for its one pane", () => { + expect( + findPaneSleepingRecord(session({ type: 'leaf', leafId: LEFT }), WORKTREE, { + parentTabId: 'tab-1', + leafId: LEFT + }) + ).toMatchObject({ record: RECORD, aliases: ['tab-1:2'] }) + }) + + it('in a split of two agents, takes each pane’s own numbered record, as the window does', () => { + const split = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } + const twoAgents = session(split, { + 'tab-1:1': { ...RECORD, paneKey: 'tab-1:1' }, + 'tab-1:2': { ...RECORD, providerSession: { key: 'session_id', id: 'conv-2' } } + }) + const of = (leafId: string) => + findPaneSleepingRecord(twoAgents, WORKTREE, { parentTabId: 'tab-1', leafId })?.record + .providerSession.id + expect(of(LEFT)).toBe('conv-1') + expect(of(RIGHT)).toBe('conv-2') + }) + + it('takes nothing in a split for a pane with no number of its own', () => { + const split = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } + // The record is numbered 2, the right pane's number; the left pane must not take it. + expect( + findPaneSleepingRecord(session(split), WORKTREE, { parentTabId: 'tab-1', leafId: LEFT }) + ).toBeNull() + expect( + findPaneSleepingRecord(session(split), WORKTREE, { parentTabId: 'tab-1', leafId: RIGHT }) + ).toMatchObject({ record: RECORD }) + }) + + it('in a split where no pane owns a numbered record, gives the one conversation to pane 1 only', () => { + const split = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } + // Numbered 3 in a two-pane tab: no pane's own number, so the tab's first pane resumes it. + const unowned = session(split, { 'tab-1:3': { ...RECORD, paneKey: 'tab-1:3' } }) + const of = (leafId: string) => + findPaneSleepingRecord(unowned, WORKTREE, { parentTabId: 'tab-1', leafId }) + expect(of(LEFT)).toMatchObject({ record: { paneKey: 'tab-1:3' } }) + expect(of(RIGHT)).toBeNull() + }) + + it('never gives one numbered record to two panes of a split', () => { + const split = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + } + for (const paneKey of ['tab-1:2', 'tab-1:3']) { + const records = session(split, { [paneKey]: { ...RECORD, paneKey } }) + const takers = [LEFT, RIGHT].filter((leafId) => + findPaneSleepingRecord(records, WORKTREE, { parentTabId: 'tab-1', leafId }) + ) + expect(takers).toHaveLength(1) + } + }) +}) diff --git a/src/main/runtime/session-lifecycle/projection/pane-sleeping-record.ts b/src/main/runtime/session-lifecycle/projection/pane-sleeping-record.ts new file mode 100644 index 00000000000..c8d4f6ade28 --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/pane-sleeping-record.ts @@ -0,0 +1,57 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { findLegacyNumericSleepingRecord } from '../../../../shared/sleeping-record-legacy-pane-key' +import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' +import { resolveRuntimePaneTitleLeafIdFromRoot } from '../../../../shared/runtime-pane-title-leaf-id' +import { worktreeIdsEqual } from '../../../../shared/worktree/id' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { collectPersistedTerminalLeafIds } from '../../mobile-session-layout-projection' + +/** The pane's record and, for a numbered one, its tab's numbered keys: aliases, not other panes. */ +export function findPaneSleepingRecord( + session: WorkspaceSessionState | null | undefined, + worktreeId: string, + pane: { parentTabId: string; leafId: string } +): { record: SleepingAgentSessionRecord; aliases: readonly string[] } | null { + const records = session?.sleepingAgentSessionsByPaneKey + const record = records?.[makePaneKey(pane.parentTabId, pane.leafId)] + if (record) { + return worktreeIdsEqual(record.worktreeId, worktreeId) ? { record, aliases: [] } : null + } + // Switch point: main starts every pane, so it reads the numbered records the window read. + if (!ORCA_LAYOUT_OWNER_IS_MAIN || !records) { + return null + } + // The pane numbers the tab's records name, mapped to panes as the window numbers a restored tab. + const root = layoutOf(session, pane)?.root + const inWorkspace = (candidate: SleepingAgentSessionRecord) => + worktreeIdsEqual(candidate.worktreeId, worktreeId) + const owned = Object.entries(records).flatMap(([paneKey, candidate]) => { + const legacy = parseLegacyNumericPaneKey(paneKey) + if (legacy?.tabId !== pane.parentTabId || !inWorkspace(candidate)) { + return [] + } + const leafId = resolveRuntimePaneTitleLeafIdFromRoot(root, legacy.numericPaneId) + return leafId ? [{ leafId, numericPaneId: legacy.numericPaneId }] : [] + }) + const numericPaneId = owned.find((entry) => entry.leafId === pane.leafId)?.numericPaneId + const found = findLegacyNumericSleepingRecord( + records, + { tabId: pane.parentTabId, numericPaneId }, + inWorkspace + ) + const exact = parseLegacyNumericPaneKey(found?.paneKey)?.numericPaneId === numericPaneId + // Why: in a split the one-conversation fallback goes to pane 1 alone, and only when no pane + // owns a numbered record, so no two panes resume one conversation. + const fallback = + collectPersistedTerminalLeafIds(layoutOf(session, pane)).length <= 1 || + (owned.length === 0 && resolveRuntimePaneTitleLeafIdFromRoot(root, '1') === pane.leafId) + return found && (exact || fallback) ? found : null +} + +function layoutOf( + session: WorkspaceSessionState | null | undefined, + pane: { parentTabId: string } +) { + return session?.terminalLayoutsByTabId?.[pane.parentTabId] +} diff --git a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts index 233b722a75a..2e322c76b86 100644 --- a/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts +++ b/src/main/runtime/session-lifecycle/projection/restored-pane-materialize.ts @@ -8,12 +8,15 @@ import type { } from '../../terminal/launch/mobile-terminal-startup' import { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs } from './build-headless-mobile-session-browser-tabs' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { markResumedRecordLive } from './window-wake-sleep-record' import { isWorktreeTerminalsSleepingError } from '../../worktree-terminals-sleeping-error' import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-session-contracts' import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' +import { buildHeadlessMobileSessionTerminalTabs } from '../../mobile-session-terminal-projection' import { RestoredPaneStarter, type RestoredPane, @@ -33,6 +36,9 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu protected abstract resolvePaneOwnedResumeStartup( ...args: Parameters ): ReturnType + protected abstract commitWorkspaceSessionEdit( + ...args: Parameters + ): ReturnType abstract subscribeWorkspaceLayouts( listener: Parameters[0] @@ -40,6 +46,12 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu private restoredPanes: RestoredPaneStarter | null = null + /** Publishes the pane's start status (layout-owner build): starting, or failed with its reason. */ + protected abstract showRestoredPaneStart( + paneKey: string, + start: 'starting' | { failed: unknown } + ): void + /** * Layout-owner build: the pane's live terminal, started once if it has none, for any trigger * but a desktop window's own restore. Null when main no longer holds the pane. @@ -51,16 +63,25 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu this.restoredPanes ??= new RestoredPaneStarter({ boundPtyId: (target) => this.storedPaneBinding(target), isLive: (ptyId) => this.ptysById.get(ptyId)?.connected === true, + starting: (target) => + this.showRestoredPaneStart(`${target.tabId}:${target.leafId}`, 'starting'), + failed: (target, error) => + this.showRestoredPaneStart(`${target.tabId}:${target.leafId}`, { failed: error }), start: async (target, plan, startTrigger) => { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(target.worktreeId) - const tab = this.mobileSessionTabsByWorktree - .get(target.worktreeId) - ?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && - candidate.parentTabId === target.tabId && - candidate.leafId === target.leafId - ) + // Why main's layout, not the client projection: at relaunch a window's view can ask before + // its graph lists the tab, and the projection is not built while a window is attached. + const session = this.getWorkspaceSessionForWorktree(target.worktreeId) + const tab = session + ? buildHeadlessMobileSessionTerminalTabs( + target.worktreeId, + session.tabsByWorktree?.[target.worktreeId] ?? [], + session + ).find( + (candidate) => + candidate.parentTabId === target.tabId && candidate.leafId === target.leafId + ) + : undefined if (!tab) { throw new Error('tab_not_found') } @@ -79,6 +100,16 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu return this.restoredPanes.ensureStarted(pane, trigger) } + /** Main is stopping this terminal to restart its pane: its earlier start no longer stands. */ + forgetRestoredPaneStart(ptyId: string): void { + this.restoredPanes?.forget(ptyId) + } + + /** Whether main's layout binds the pane to this terminal now: a view's ack names its current one. */ + isRestoredPaneBoundTo(pane: RestoredPane, ptyId: string): boolean { + return this.storedPaneBinding(pane) === ptyId + } + /** Layout-owner build: a paired desktop's Reopen of the panes main publishes held. */ reopenHeldTerminalPanes(panes: readonly RestoredPane[]): Promise { // Why the published layout: its status is the one the desktop's banner showed. @@ -127,6 +158,12 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu // Why: an agent already ran here and the host recorded its session; resume it rather than // start a bare shell or a fresh agent (#27147, #27777). const resumeStartup = await this.resolvePaneOwnedResumeStartup(worktreeId, tab) + // Switch point: the record a resume starts from (after any claim), marked live once it runs. + const paneKey = `${tab.parentTabId}:${tab.leafId}` + const resumedRecord = + ORCA_LAYOUT_OWNER_IS_MAIN && resumeStartup && 'resumeProviderSession' in resumeStartup + ? this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[paneKey] + : undefined let agentStartup: MobileTerminalCommand | MobileTerminalResumeCommand = resumeStartup ?? {} if (resumeStartup === undefined && tab.launchAgent) { let workspace @@ -173,6 +210,15 @@ export abstract class OrcaRuntimeWithRestoredPaneStart extends OrcaRuntimeWithBu ...(automatic ? { refuseSleptWorktree: true } : {}) } ) + // Switch point: under main the resumed agent runs, so its record must not read as a sleep. + if (created.tab.ptyId && resumedRecord) { + const hostId = this.getWorkspaceSessionHostIdForWorktree(worktreeId) + void markResumedRecordLive( + (edit) => this.commitWorkspaceSessionEdit(hostId, edit), + paneKey, + resumedRecord + ).catch((err: unknown) => console.warn('[layout] could not mark a resumed pane live', err)) + } return created } catch (err) { if ( diff --git a/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.test.ts b/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.test.ts new file mode 100644 index 00000000000..742531ca847 --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { markResumedRecordLive } from './window-wake-sleep-record' + +const PANE_KEY = 'tab-1:leaf-1' +const RESUMED: SleepingAgentSessionRecord = { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'repo::/work', + agent: 'claude', + providerSession: { key: 'session_id', id: 'conv-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' +} + +async function markedRecord(stored: SleepingAgentSessionRecord) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the edit reads only the records. + let session = { + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: stored } + } as unknown as WorkspaceSessionState + await markResumedRecordLive( + async (edit) => { + session = edit(session) ?? session + }, + PANE_KEY, + RESUMED + ) + return session.sleepingAgentSessionsByPaneKey?.[PANE_KEY] +} + +describe('marking a resumed pane record live', () => { + it('turns the record the start resumed from into a running agent’s', async () => { + expect(await markedRecord(RESUMED)).toMatchObject({ origin: 'live', capturedAt: 1 }) + }) + + it('leaves a sleep of the pane captured after the start', async () => { + const sleptAgain = { ...RESUMED, capturedAt: 2, updatedAt: 2 } + expect(await markedRecord(sleptAgain)).toEqual(sleptAgain) + }) +}) diff --git a/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.ts b/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.ts index 4e5432e1d65..c8d0ad471cc 100644 --- a/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.ts +++ b/src/main/runtime/session-lifecycle/projection/window-wake-sleep-record.ts @@ -1,5 +1,6 @@ import { agentProviderSessionsEqual, + isPaneSleepRecord, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' @@ -67,3 +68,28 @@ export async function claimWindowWakeRecordForPane( } return owned } + +/** + * Layout-owner build: once a pane's start resumed its slept agent from `resumed`, that record is a + * running agent's resume record ('live'), so the pane reads awake while a host crash still resumes + * it. A record captured since (a new sleep of the pane) is left as it is. + */ +export async function markResumedRecordLive( + commit: SessionEdit, + paneKey: string, + resumed: SleepingAgentSessionRecord +): Promise { + await commit((session) => { + const stored = session.sleepingAgentSessionsByPaneKey?.[paneKey] + if (!stored || !isPaneSleepRecord(stored) || !sameRecord(stored, resumed)) { + return null + } + return { + ...session, + sleepingAgentSessionsByPaneKey: { + ...session.sleepingAgentSessionsByPaneKey, + [paneKey]: { ...stored, origin: 'live' } + } + } + }) +} diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index d156b625091..f2496cc56f0 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -1,10 +1,17 @@ // Layout-owner build: main's starts publish their status on the pane, and a start main could not // make leaves its pane showing why, whether it failed before the pane was written or after. import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { isHibernationDoneRecord } from '../../../../shared/hibernation-done-record' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types' import type * as dependencies from '../../orca-runtime-create-terminal-dependencies' import { PaneStartStatuses, paneStartFailure } from '../../pane-start-statuses' -import type { WindowLayoutCommand } from '../../runtime-layout-apply' +import type { LayoutCommandRunOptions, WindowLayoutCommand } from '../../runtime-layout-apply' +import { + runLayoutPaneStarts, + startsPanes, + stopReplacedTerminals +} from '../recovery/layout-command-pane-starts' import { allocateTerminalPaneIdentity } from '../../runtime-terminal-pane-identity' import { OrcaRuntimeWithCreateTerminal } from './terminal-create' import { mainOwnsTabLayout } from './main-owned-tab-layout' @@ -38,6 +45,58 @@ export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCre ? new PaneStartStatuses(() => this.markPaneLiveTitlesDirty()) : null + /** + * Layout-owner build: main's side of a client's startPane, wake or restartPane, its live start + * statuses in and its effects out. Undefined for any other command, and with the switch off. + */ + protected layoutCommandRun(command: WindowLayoutCommand): LayoutCommandRunOptions | undefined { + if (!ORCA_LAYOUT_OWNER_IS_MAIN || command.type === 'renameTabs' || !startsPanes(command)) { + return undefined + } + const { workspace } = command + return { + live: { + // Why unverifiable counts as live: lost contact is never evidence the terminal ended. + isBindingLive: (paneKey: string, ptyId: string) => + (this.ptysById.get(ptyId)?.connected === true || + this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'unverifiable') && + !this.paneStartStatuses?.peek(paneKey), + // Why local only: a remote or SSH terminal may hold output this host never saw. + retiresBinding: (paneKey: string, ptyId: string) => { + const pty = this.ptysById.get(ptyId) + const record = + this.getWorkspaceSessionForWorktree(workspace)?.sleepingAgentSessionsByPaneKey?.[ + paneKey + ] + return ( + pty?.connected === true && + pty.connectionId === null && + !isRemoteRuntimePtyId(ptyId) && + pty.lastOutputAt == null && + record !== undefined && + isHibernationDoneRecord(record) + ) + } + }, + stopBeforeWrite: (ptyIds) => + stopReplacedTerminals(ptyIds, (ptyId) => { + this.forgetRestoredPaneStart(ptyId) + return this.ptyController?.kill(ptyId) ?? false + }), + onEffects: (effects) => + runLayoutPaneStarts({ type: command.type, workspace }, effects, (pane) => + this.ensureRestoredPaneStarted(pane) + ) + } + } + + protected showRestoredPaneStart(paneKey: string, start: 'starting' | { failed: unknown }): void { + this.paneStartStatuses?.set( + paneKey, + start === 'starting' ? { status: 'starting' } : paneStartFailure(start.failed) + ) + } + protected paneStartStatusArgs: dependencies.PaneStartArgsOf = (opts) => { const sink = this.paneStartStatuses return sink diff --git a/src/main/runtime/terminal/recovery/layout-command-pane-starts.test.ts b/src/main/runtime/terminal/recovery/layout-command-pane-starts.test.ts new file mode 100644 index 00000000000..5f67540bdf9 --- /dev/null +++ b/src/main/runtime/terminal/recovery/layout-command-pane-starts.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { runLayoutPaneStarts, stopReplacedTerminals } from './layout-command-pane-starts' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF}` + +describe('runLayoutPaneStarts', () => { + it('starts each named pane through the starter', async () => { + const start = vi.fn(async () => ({ id: 'pty-1' })) + runLayoutPaneStarts( + { type: 'restartPane', workspace: 'wt' }, + { stopPtyIds: ['pty-old'], startPaneKeys: [PANE_KEY] }, + start + ) + await vi.waitFor(() => + expect(start).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'wt', + tabId: 'tab-1', + leafId: LEAF + }) + ) + }) + + it('runs no start for a create, which starts through its own path', () => { + const start = vi.fn(async () => ({ id: 'pty-1' })) + runLayoutPaneStarts( + { type: 'createTerminalTab', workspace: 'wt' }, + { stopPtyIds: [], startPaneKeys: [PANE_KEY] }, + start + ) + expect(start).not.toHaveBeenCalled() + }) +}) + +describe('stopReplacedTerminals', () => { + it('stops every replaced terminal', () => { + const stop = vi.fn(() => true) + expect(stopReplacedTerminals(['pty-a', 'pty-b'], stop)).toBe(true) + expect(stop.mock.calls).toEqual([['pty-a'], ['pty-b']]) + }) + + it('says so when a terminal would not stop', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(stopReplacedTerminals(['pty-old'], () => false)).toBe(false) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/terminal/recovery/layout-command-pane-starts.ts b/src/main/runtime/terminal/recovery/layout-command-pane-starts.ts new file mode 100644 index 00000000000..83b3578e943 --- /dev/null +++ b/src/main/runtime/terminal/recovery/layout-command-pane-starts.ts @@ -0,0 +1,63 @@ +// Layout-owner build: what main runs after a client's startPane, wake or restartPane is written. +// Every start goes through main's one restored-pane starter, so racing triggers start one terminal. + +import type { + LayoutEffects, + LayoutCommand +} from '../../../../shared/workspace-layout/workspace-layout-command-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { RestoredPane, StartedPty } from './restored-pane-start' + +/** Main's restored-pane starter: the pane's terminal, or null once main no longer holds it. */ +export type LayoutPaneStart = (pane: RestoredPane) => Promise + +const STARTING_COMMANDS: ReadonlySet = new Set([ + 'startPane', + 'wake', + 'restartPane' +]) + +/** Whether main runs this command's effects; a create or split starts through its own path. */ +export function startsPanes(command: Pick): boolean { + return STARTING_COMMANDS.has(command.type) +} + +/** + * Stops the terminals a command replaces, before its write, through the existing stop path. Never + * silent: a terminal that would not stop is logged, and its pane keeps it and starts nothing. + */ +export function stopReplacedTerminals( + ptyIds: readonly string[], + stop: (ptyId: string) => boolean +): boolean { + const unstopped = ptyIds.filter((ptyId) => !stop(ptyId)) + if (unstopped.length > 0) { + console.warn('[layout] a restart could not stop its terminal; nothing started', unstopped) + return false + } + return true +} + +/** + * Starts each pane the written command names; its replaced terminals stopped before the write. + * Never silent: the starter shows a failed start on the pane. A wake keeps its records: the start + * resumes from them and keeps them in the pane-owned form, so a host crash resumes the agent again. + */ +export function runLayoutPaneStarts( + command: Pick, + effects: LayoutEffects, + start: LayoutPaneStart +): void { + if (!startsPanes(command)) { + return + } + for (const paneKey of effects.startPaneKeys) { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + continue + } + const pane = { worktreeId: command.workspace, tabId: parsed.tabId, leafId: parsed.leafId } + // The starter shows a failed start on the pane. + void start(pane).catch(() => {}) + } +} diff --git a/src/main/runtime/terminal/recovery/restored-pane-start.test.ts b/src/main/runtime/terminal/recovery/restored-pane-start.test.ts index a71a4eb2efd..a45624fdba1 100644 --- a/src/main/runtime/terminal/recovery/restored-pane-start.test.ts +++ b/src/main/runtime/terminal/recovery/restored-pane-start.test.ts @@ -3,7 +3,8 @@ import { RestoredPaneStarter, restoredPaneStartPlan, type RestoredPane, - type RestoredPaneStartPorts + type RestoredPaneStartPorts, + type StartedPty } from './restored-pane-start' const PANE: RestoredPane = { @@ -49,6 +50,49 @@ describe('restoredPaneStartPlan', () => { }) describe('RestoredPaneStarter', () => { + it('shows the start before any await, and a failure with no winner', async () => { + const starting = vi.fn() + const failed = vi.fn() + const error = new Error('boom') + const starter = new RestoredPaneStarter({ + boundPtyId: () => OWN, + isLive: () => false, + starting, + failed, + start: vi.fn(async (): Promise => { + throw error + }) + }) + + const started = starter.ensureStarted(PANE) + expect(starting).toHaveBeenCalledWith(PANE) + await expect(started).rejects.toBe(error) + expect(failed).toHaveBeenCalledWith(PANE, error) + }) + + it('starts once for a trigger that arrives after the start but before its binding is written', async () => { + const live = new Set() + const start = vi.fn(async () => { + live.add('pty-new') + return { id: 'pty-new' } + }) + // The stored layout still names the old terminal: the bind write has not landed. + const starter = new RestoredPaneStarter({ + boundPtyId: () => OWN, + isLive: (ptyId) => live.has(ptyId), + start + }) + + await expect(starter.ensureStarted(PANE)).resolves.toEqual({ id: 'pty-new' }) + await expect(starter.ensureStarted(PANE)).resolves.toEqual({ id: 'pty-new' }) + expect(start).toHaveBeenCalledOnce() + + // Once that terminal is gone, the next trigger starts the pane again. + live.delete('pty-new') + await starter.ensureStarted(PANE) + expect(start).toHaveBeenCalledTimes(2) + }) + it('starts a pane with no live terminal once for concurrent triggers, all bound to it', async () => { const { ports: p, start } = ports() const starter = new RestoredPaneStarter(p) diff --git a/src/main/runtime/terminal/recovery/restored-pane-start.ts b/src/main/runtime/terminal/recovery/restored-pane-start.ts index 3bf6135bbfe..4461556d3cc 100644 --- a/src/main/runtime/terminal/recovery/restored-pane-start.ts +++ b/src/main/runtime/terminal/recovery/restored-pane-start.ts @@ -26,6 +26,10 @@ export type RestoredPaneStartPorts = { /** The pane's stored terminal, or null for a pane main no longer holds. */ boundPtyId: (pane: RestoredPane) => string | null | undefined isLive: (ptyId: string) => boolean + /** Shows the pane starting, before any await, so no view starts it too. */ + starting?: (pane: RestoredPane) => void + /** Shows a start that failed, with its reason; the pane keeps what it restores from. */ + failed?: (pane: RestoredPane, error: unknown) => void /** Starts the pane in place, resuming its recorded agent; resolves to the terminal bound to it. */ start: ( pane: RestoredPane, @@ -46,6 +50,8 @@ export function restoredPaneStartPlan( export class RestoredPaneStarter { private readonly inFlight = new Map>() + /** Each pane's last started terminal: its bind write can land after the start resolves. */ + private readonly started = new Map() constructor(private readonly ports: RestoredPaneStartPorts) {} @@ -62,18 +68,32 @@ export class RestoredPaneStarter { } const bound = this.ports.boundPtyId(pane) if (bound === null) { + this.started.delete(key) return Promise.resolve(null) } if (this.isOwnLive(pane, bound)) { + this.started.delete(key) return Promise.resolve({ id: bound }) } + const started = this.started.get(key) + // Why: until the started terminal's binding is written, the stored layout still names the old one. + if (started && this.ports.isLive(started.id)) { + return Promise.resolve(started) + } + this.started.delete(key) + this.ports.starting?.(pane) const start = this.ports .start(pane, restoredPaneStartPlan(pane, bound), trigger) + .then((pty) => { + this.started.set(key, pty) + return pty + }) .catch((error: unknown) => { const winner = this.ports.boundPtyId(pane) if (this.isOwnLive(pane, winner)) { return { id: winner } } + this.ports.failed?.(pane, error) throw error }) .finally(() => { @@ -85,6 +105,15 @@ export class RestoredPaneStarter { return start } + /** A stopped terminal is no pane's start any more, so a restart into its pane starts afresh. */ + forget(ptyId: string): void { + for (const [key, started] of this.started) { + if (started.id === ptyId) { + this.started.delete(key) + } + } + } + // A live terminal another workspace owns is never this pane's: it starts fresh instead. private isOwnLive(pane: RestoredPane, ptyId: string | null | undefined): ptyId is string { return ( diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 092112c1f0a..d4fd7ec7c40 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -1,3 +1,4 @@ +import type { PaintedRestorePane } from '../../shared/painted-restore-ack' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { AgentProviderSessionMetadata, @@ -52,6 +53,8 @@ export type PtyApi = { terminalColorQueryReplies?: { foreground?: string; background?: string } // Why: mark the PTY hidden before its first byte so the delivery gate owns spawn-time queries (terminal-query-authority.md §races). initiallyHidden?: boolean + // Layout-owner build: attach to `sessionId` or start nothing; older mains ignore it. + attachOnlyView?: boolean // Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md). tabId?: string leafId?: string @@ -106,7 +109,8 @@ export type PtyApi = { clearBuffer: (id: string) => void resetInputModes: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise - ackColdRestore: (id: string) => void + /** The view painted the restore; `pane` names the pane it painted (layout-owner build). */ + ackColdRestore: (id: string, pane?: PaintedRestorePane) => void /** Hidden-output recovery painted main's snapshot; retires the hidden-delivery drop latch. */ ackHiddenOutputRestoreApplied: (id: string, hiddenDropEpisode?: number) => void ackData: (id: string, charCount: number, processedChars?: number) => void diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index ab917f5d8bc..3f801485196 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,3 +1,4 @@ +import type { PaintedRestorePane } from '../../shared/painted-restore-ack' import type { SpawnIntent } from '../../shared/host-spawn-admission' import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' @@ -118,8 +119,8 @@ export const ptySessionControlApi = { resetInputModes: (id: string): void => { ipcRenderer.send('pty:resetInputModes', { id }) }, - ackColdRestore: (id: string): void => { - ipcRenderer.send('pty:ackColdRestore', { id }) + ackColdRestore: (id: string, pane?: PaintedRestorePane): void => { + ipcRenderer.send('pty:ackColdRestore', pane ? { id, pane } : { id }) }, ackHiddenOutputRestoreApplied: (id: string, hiddenDropEpisode?: number): void => { ipcRenderer.send('pty:hiddenOutputRestoreApplied', { id, hiddenDropEpisode }) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index 990d94b9006..e48a01ab822 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -129,6 +129,7 @@ export async function spawnIpcPty( ...(connectionId ? { connectionId } : {}), ...(admittedSessionId ? { sessionId: admittedSessionId } : {}), ...(connectOptions.initiallyHidden ? { initiallyHidden: true } : {}), + ...(connectOptions.attachOnlyView ? { attachOnlyView: true } : {}), worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts index fa741e8afe0..569375b9daf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts @@ -1,6 +1,14 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectNoWindowStart, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { flushAsyncTicks } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' import { @@ -29,6 +37,9 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { return buildPaneConnectionDeps(() => mockStoreState, overrides) } + /** Main's frame binding tab-1's pane 2. */ + const bindLeaf2 = bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_2) + describe('connectPanePty', () => { beforeEach(async () => { vi.resetModules() @@ -47,6 +58,7 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -118,6 +130,15 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes the agent; the window only attaches to the cold-restored terminal and paints it. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'lost-pty', attachOnlyView: true }) + ) + expectNoWindowStart(transport, mockStoreState) + expect(transport.sendInput).not.toHaveBeenCalled() + return + } const reattachArgs = transport.connect.mock.calls.find( ([args]) => args.sessionId === 'lost-pty' )?.[0] @@ -303,6 +324,15 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes from the live entry; the window only attaches and paints. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'lost-pty', attachOnlyView: true }) + ) + expectNoWindowStart(transport, mockStoreState) + expect(transport.sendInput).not.toHaveBeenCalled() + return + } expect(transport.sendInput).not.toHaveBeenCalled() expect(transport.connect).toHaveBeenCalledWith( expect.objectContaining({ @@ -386,6 +416,16 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main's start carries the one resume; the window's one connect is an attach. + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: retainedPtyId, attachOnlyView: true }) + ) + expectNoWindowStart(transport, mockStoreState) + expect(mockStoreState.tabsByWorktree['wt-1']).toHaveLength(1) + return + } expect(transport.connect).toHaveBeenCalledTimes(1) expect(transport.attach).not.toHaveBeenCalled() const options = transport.connect.mock.calls[0]?.[0] as { @@ -474,6 +514,14 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main picks the resume recipe; the window neither builds nor registers one. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'lost-pty', attachOnlyView: true }) + ) + expectNoWindowStart(transport, mockStoreState) + return + } expect(mockStoreState.getAgentLaunchConfigForStatusEntry).toHaveBeenCalledWith( expect.objectContaining({ paneKey, agentType: 'codex' }) ) @@ -555,6 +603,7 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { restoredLeafId: LEAF_2, restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } }) + await mainStartsPaneAs({ ptyId: 'pty-main', bind: bindLeaf2, transport }) connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(10) @@ -563,6 +612,17 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { 1, expect.objectContaining({ sessionId: staleSessionId }) ) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The reattach found no terminal: main starts the pane and keeps its record; the window + // starts no fresh shell of its own. + await expectWindowStartAskedMain(transport, paneKey, mockStoreState) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + return + } expect(transport.connect).toHaveBeenNthCalledWith( 2, expect.not.objectContaining({ sessionId: expect.any(String) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts index cc49283bad4..c9a846a0d58 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts @@ -1,6 +1,11 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + expectWindowPaintsColdRestoreOnly, + mainLayoutApplyTransport +} from './pty-connection-test-main-pane-start-fixtures' import { RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' import { flushAsyncTicks } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' @@ -47,6 +52,7 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -105,6 +111,12 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. + expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') + return + } + expect(pane.terminal.write).toHaveBeenCalledWith( `${RESET_GRAPHIC_RENDITION}cold-payload`, expect.any(Function) @@ -200,6 +212,12 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. + expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') + return + } + expect(pane.terminal.write).toHaveBeenCalledWith( `${RESET_GRAPHIC_RENDITION}cold-payload`, expect.any(Function) @@ -278,6 +296,12 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. + expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') + return + } + expect(pane.terminal.write).toHaveBeenCalledWith( `${RESET_GRAPHIC_RENDITION}cold-payload`, expect.any(Function) @@ -367,6 +391,12 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. + expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') + return + } + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') }) @@ -434,6 +464,12 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { await flushAsyncTicks(20) await vi.advanceTimersByTimeAsync(70) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main resumes the agent; the window attaches, paints the host's history, and resumes nothing. + expectWindowPaintsColdRestoreOnly(pane, transport, deps, mockStoreState, 'lost-pty') + return + } + expect(pane.terminal.write).toHaveBeenCalledWith( `${RESET_GRAPHIC_RENDITION}cold-payload`, expect.any(Function) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts index e5b4a543fda..049067f3726 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts @@ -6,6 +6,7 @@ import { RELEASE_SYNCHRONIZED_OUTPUT } from '../../../../shared/terminal-mode-reset-profiles' import { Terminal } from '@xterm/headless' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { flushAsyncTicks, @@ -136,7 +137,13 @@ export function registerPtyConnectionColdRestoreRepaintTests(): void { ) expect(pane.terminal.write).not.toHaveBeenCalledWith('cold-payload', expect.any(Function)) // Why: the replay branch supersedes cold-restore but must still ack, or the daemon redelivers the cold-restore payload next reattach. - expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty') + // A layout-owner window names the pane it painted, so main releases only its current terminal. + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith( + 'tab-pty', + ...(ORCA_LAYOUT_OWNER_IS_MAIN + ? [{ worktreeId: deps.worktreeId, tabId: deps.tabId, leafId: LEAF_1 }] + : []) + ) }) it('blanks restored scrollback before fresh shell output', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts index 9c1810b8c5c..8aba9596428 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.spec.ts @@ -1,6 +1,12 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + expectNoWindowStart, + expectWindowStartAskedMain, + mainLayoutApplyTransport +} from './pty-connection-test-main-pane-start-fixtures' import { flushAsyncTicks } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' import { temporarilySetNavigatorUserAgent } from './pty-connection-test-dom' @@ -47,6 +53,7 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -130,6 +137,16 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { } await flushAsyncTicks(10) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Retiring a contentless shell to resume is main's start: the window drops its view of + // the shell and asks main, which replaces it and resumes the agent. + expect(transport.disconnect).toHaveBeenCalled() + await expectWindowStartAskedMain(transport, paneKey) + expectNoWindowStart(transport, mockStoreState) + expect(transport.sendInput).not.toHaveBeenCalled() + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + return + } expect(transport.disconnect).toHaveBeenCalledTimes(1) expect(transport.connect).toHaveBeenCalledTimes(2) expect(transport.connect).toHaveBeenNthCalledWith( @@ -154,6 +171,10 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { } }) + /** The command the window's own resume types; with the switch on main's start resumes, so none. */ + const windowResumeCommand = (command: string): string | undefined => + ORCA_LAYOUT_OWNER_IS_MAIN ? undefined : command + // Regression (#12320): a cold restore after reboot typed PowerShell single quotes into // cmd.exe tabs, so the agent CLI rejected the resume argv ("unexpected argument"). async function runWindowsColdRestoreResume(args: { @@ -255,7 +276,7 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { it('quotes a cold-restore resume command for a cmd.exe Windows tab', async () => { await expect(runWindowsColdRestoreResume({ terminalWindowsShell: 'cmd.exe' })).resolves.toBe( - 'codex "resume" "codex-session-1"' + windowResumeCommand('codex "resume" "codex-session-1"') ) }) @@ -265,13 +286,13 @@ export function registerPtyConnectionColdRestoreResumeCommandTests(): void { terminalWindowsShell: 'powershell.exe', tabShellOverride: 'cmd.exe' }) - ).resolves.toBe('codex "resume" "codex-session-1"') + ).resolves.toBe(windowResumeCommand('codex "resume" "codex-session-1"')) }) it('keeps PowerShell quoting for a cold-restore resume on a PowerShell Windows tab', async () => { await expect( runWindowsColdRestoreResume({ terminalWindowsShell: 'powershell.exe' }) - ).resolves.toBe("codex 'resume' 'codex-session-1'") + ).resolves.toBe(windowResumeCommand("codex 'resume' 'codex-session-1'")) }) async function expectLiveNoteKeepsContentlessReattach( diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts index 01f36f8b799..53241b533f3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.spec.ts @@ -2,6 +2,14 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' import { sendTerminalInputThroughPane } from './pty-connection-test-dom' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectNoWindowStart, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { LEAF_2, leafIdForPane, @@ -46,6 +54,7 @@ export function registerPtyConnectionHibernationWakeTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -65,6 +74,12 @@ export function registerPtyConnectionHibernationWakeTests(): void { }) const pane = createPane(2) const paneKey = `tab-1:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) + } mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId: 'tab-1', @@ -105,6 +120,18 @@ export function registerPtyConnectionHibernationWakeTests(): void { binding.noteVisibilityResume() await flushAsyncTicks() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The wake asks main to start the pane; the window attaches to what main binds. + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + // One-shot: a second reveal asks again for nothing. + binding.noteVisibilityResume() + await flushAsyncTicks() + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toHaveLength(1) + return + } expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as | { command?: string } @@ -131,6 +158,12 @@ export function registerPtyConnectionHibernationWakeTests(): void { }) const pane = createPane(2) const paneKey = `tab-1:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) + } mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId: 'tab-1', @@ -159,6 +192,14 @@ export function registerPtyConnectionHibernationWakeTests(): void { binding.noteVisibilityResume() await flushAsyncTicks() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The wake asks main to start the pane; the window attaches to what main binds. + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + return + } const resumeConnectOptions: { command?: string } | undefined = transport.connect.mock.calls.at(-1)?.[0] expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) @@ -177,6 +218,12 @@ export function registerPtyConnectionHibernationWakeTests(): void { }) const pane = createPane(2) const paneKey = `tab-1:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) + } mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId: 'tab-1', @@ -216,6 +263,18 @@ export function registerPtyConnectionHibernationWakeTests(): void { expect(claimedProviderSessions).toEqual(new Set([claimKey])) await flushAsyncTicks() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The wake asks main to start the pane; the window attaches to what main binds. + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + // One-shot: a second wake asks again for nothing. + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toHaveLength(1) + return + } expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as | { command?: string } @@ -260,6 +319,12 @@ export function registerPtyConnectionHibernationWakeTests(): void { expect(createdTransportOptions[0]).not.toHaveProperty('replacesPtyId') const paneKey = `tab-restart-wake:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-restart-wake', leafIdForPane(2)) + }) + } mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId: 'tab-restart-wake', @@ -280,6 +345,20 @@ export function registerPtyConnectionHibernationWakeTests(): void { await flushAsyncTicks() expect(binding.wakeHibernatedAgentIfArmed(new Set())).not.toBeNull() await flushAsyncTicks() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The wake asks main; its attach to main's terminal names no PTY to replace. + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect.mock.calls[1]?.[0]).toMatchObject({ + sessionId: 'pty-main', + attachOnlyView: true + }) + expect(transport.connect.mock.calls[1]?.[0]).not.toHaveProperty('claimReplacedPtyId') + binding.dispose() + await flushAsyncTicks() + expect(window.api.pty.kill).not.toHaveBeenCalledWith('pty-replaced') + return + } expect(transport.connect).toHaveBeenCalledTimes(2) expect(transport.connect.mock.calls[1]?.[0]).not.toHaveProperty('claimReplacedPtyId') @@ -360,6 +439,12 @@ export function registerPtyConnectionHibernationWakeTests(): void { }) const pane = createPane(2) const paneKey = `tab-1:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) + } mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId: 'tab-1', @@ -395,6 +480,14 @@ export function registerPtyConnectionHibernationWakeTests(): void { onPtyExit?.('tab-pty') await flushAsyncTicks() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The wake asks main to start the pane; the window attaches to what main binds. + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + return + } // Arming consumed the latched wake — the --resume spawned with no reveal and no second wake event. expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as @@ -443,6 +536,23 @@ export function registerPtyConnectionHibernationWakeTests(): void { transport.connect.mockImplementationOnce(() => deferredSpawn.promise) const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-inflight' + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The pane claims its session while main's start runs, asks main once, and leaves the + // record to main once the start settles. + const layoutApply = await mainLayoutApplyTransport() + layoutApply.answerWith('pending') + const claim = 'wt-1\0claude\0session_id\0sess-hibernated-inflight' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claim) + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claim) + await flushAsyncTicks() + expect(layoutApply.sentOfType('startPane')).toHaveLength(1) + layoutApply.answerPending() + await layoutApply.settle() + await flushAsyncTicks() + expectNoWindowStart(transport, mockStoreState) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + return + } expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) const connectCallsAfterFirstWake = transport.connect.mock.calls.length expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) @@ -488,6 +598,20 @@ export function registerPtyConnectionHibernationWakeTests(): void { transport.connect.mockRejectedValueOnce(new Error('transient spawn failure')) const claimKey = 'wt-1\0claude\0session_id\0sess-hibernated-retry' + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main refuses the start: the wake re-arms, and the next wake asks main again. + const layoutApply = await mainLayoutApplyTransport() + layoutApply.answerWith({ refuse: 'pane_not_found' }) + const claim = 'wt-1\0claude\0session_id\0sess-hibernated-retry' + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claim) + await layoutApply.settle() + await flushAsyncTicks(20) + expect(binding.wakeHibernatedAgentIfArmed()).toBe(claim) + await layoutApply.settle() + expect(layoutApply.sentOfType('startPane')).toHaveLength(2) + expectNoWindowStart(transport, mockStoreState) + return + } expect(binding.wakeHibernatedAgentIfArmed()).toBe(claimKey) await flushAsyncTicks(20) const connectCallsAfterFailure = transport.connect.mock.calls.length @@ -537,6 +661,12 @@ export function registerPtyConnectionHibernationWakeTests(): void { }) const pane = createPane(2) const paneKey = `tab-1:${leafIdForPane(2)}` + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) + } mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { paneKey, tabId: 'tab-1', @@ -566,6 +696,18 @@ export function registerPtyConnectionHibernationWakeTests(): void { onPtyExit?.('tab-pty') await flushAsyncTicks() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The wake asks main to start the pane; the window attaches to what main binds. + await expectWindowStartAskedMain(transport, paneKey) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + // One-shot: a later reveal asks again for nothing. + binding.noteVisibilityResume() + await flushAsyncTicks() + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toHaveLength(1) + return + } // No second reveal was needed: the foreground pane resumed its recorded session directly from the arm-time wake. expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts index 66c177b6a90..ed390e0dd4b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts @@ -1,4 +1,4 @@ -import { describe } from 'vitest' +import { describe, vi } from 'vitest' import { registerPtyConnectionHiddenAtlasRecoveryTests } from './pty-connection-hidden-atlas-recovery.spec' import { registerPtyConnectionHiddenBacklogReconciliationTests } from './pty-connection-hidden-backlog-reconciliation.spec' import { registerPtyConnectionHiddenOutputRestoreTests } from './pty-connection-hidden-output-restore.spec' @@ -11,6 +11,13 @@ import { registerPtyConnectionDaemonSnapshotReplayTests } from './pty-connection import { registerPtyConnectionDeferredReattachLiveOutputTests } from './pty-connection-deferred-reattach-live-output.spec' import { registerPtyConnectionRemotePauseDropNoticeTests } from './pty-connection-remote-pause-drop-notice.spec' +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + describe('pty-connection-hidden-atlas-recovery', registerPtyConnectionHiddenAtlasRecoveryTests) describe( 'pty-connection-hidden-backlog-reconciliation', diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts index 64b1ccaa593..ba34593e67f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts @@ -1,6 +1,14 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectNoWindowStart, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' import { LEAF_2, @@ -25,6 +33,7 @@ type HiddenPaneBinding = { const PANE_KEY = makePaneKey('tab-1', LEAF_2) const DEAD_SESSION_ID = 'wt-1@@dead-session' +const MAIN_PTY_ID = 'pty-main' /** After a relaunch: one sleeping agent pane with no live PTY. */ export function registerPtyConnectionHiddenSleepingResumeTests(): void { @@ -115,6 +124,14 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { return transport.connect.mock.calls.map((call) => call[0]?.command) } + /** Switch on: the window asked main to start the pane and attached to main's terminal. */ + async function expectAttachedToMainStart(transport: MockTransport): Promise { + await expectWindowStartAskedMain(transport, PANE_KEY, mockStoreState) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: MAIN_PTY_ID, attachOnlyView: true }) + ) + } + describe('connectPanePty', () => { beforeEach(async () => { vi.resetModules() @@ -134,6 +151,12 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { }) await installTerminalTestGlobals() connectPanePtyRef = (await import('./pty-connection')).connectPanePty + ;(await mainLayoutApplyTransport()).reset() + // Switch on: main's start of the pane binds it to `pty-main`. + await mainStartsPaneAs({ + ptyId: MAIN_PTY_ID, + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_2) + }) }) afterEach(async () => { @@ -153,6 +176,10 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { await flushAsyncTicks(10) expect(transport.connect).toHaveBeenCalledTimes(1) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectAttachedToMainStart(transport) + return + } expect(resumeCommands(transport)[0]).toContain("'resume' 'codex-session-1'") }) @@ -171,6 +198,12 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { expect(transport.connect).toHaveBeenCalledTimes(1) // The reveal takes the same reattach a visible pane would, so the host restores its scrollback. expect(transport.connect.mock.calls[0]?.[0]).toMatchObject({ sessionId: DEAD_SESSION_ID }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The reveal's reattach is a view: main's start, not the window, resumes the agent. + expect(transport.connect.mock.calls[0]?.[0]).toMatchObject({ attachOnlyView: true }) + expectNoWindowStart(transport, mockStoreState) + return + } expect(resumeCommands(transport)[0]).toContain("'resume' 'codex-session-1'") }) @@ -195,6 +228,10 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { await flushAsyncTicks(10) expect(transport.connect).toHaveBeenCalledTimes(1) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectAttachedToMainStart(transport) + return + } expect(resumeCommands(transport)[0]).toContain("'resume' 'codex-session-1'") }) @@ -246,6 +283,11 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { await flushAsyncTicks(20) expect(transport.connect).toHaveBeenCalledTimes(2) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The failed reattach asks main to start the pane; the second connect attaches to it. + await expectAttachedToMainStart(transport) + return + } expect(resumeCommands(transport)[1]).toContain("'resume' 'codex-session-1'") }) @@ -296,12 +338,14 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { // Hibernation sleeps the hidden pane again: its own wake, which the activation sweep may clean around. isVisibleRef.current = false seedSleepingPane(null, { state: 'done', origin: 'worktree-sleep' }) - mockStoreState.suppressedPtyExitIds['resumed-pty'] = true + // Switch on: the pane runs main's terminal, so that is what the sleep ends. + const resumedPtyId = ORCA_LAYOUT_OWNER_IS_MAIN ? MAIN_PTY_ID : 'resumed-pty' + mockStoreState.suppressedPtyExitIds[resumedPtyId] = true const onPtyExit = createdTransportOptions[0]?.onPtyExit if (typeof onPtyExit !== 'function') { throw new Error('expected the transport exit handler') } - onPtyExit('resumed-pty') + onPtyExit(resumedPtyId) await flushAsyncTicks(10) expect(isPaneSpawnDeferred(PANE_KEY)).toBe(false) @@ -310,6 +354,10 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { binding.noteVisibilityResume() await flushAsyncTicks(10) expect(transport.connect).toHaveBeenCalledTimes(2) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toHaveLength(2) + expectNoWindowStart(transport, mockStoreState) + } }) it('resumes a held pane whose record was re-captured for the same session', async () => { @@ -323,6 +371,10 @@ export function registerPtyConnectionHiddenSleepingResumeTests(): void { binding.noteVisibilityResume() await flushAsyncTicks(10) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectAttachedToMainStart(transport) + return + } expect(resumeCommands(transport)[0]).toContain("'resume' 'codex-session-1'") }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts index eba5c56bee1..c3781397bf7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-input-and-repaint.test.ts @@ -1,4 +1,4 @@ -import { describe } from 'vitest' +import { describe, vi } from 'vitest' import { registerPtyConnectionKittyKeyboardRestoreTests } from './pty-connection-kitty-keyboard-restore.spec' import { registerPtyConnectionMode2031SubscriptionsTests } from './pty-connection-mode-2031-subscriptions.spec' import { registerPtyConnectionReattachModeResetTests } from './pty-connection-reattach-mode-reset.spec' @@ -7,6 +7,13 @@ import { registerPtyConnectionWindowsCjkRepaintTests } from './pty-connection-wi import { registerPtyConnectionRendererRiskRepaintTests } from './pty-connection-renderer-risk-repaint.spec' import { registerPtyConnectionTerminalInputGatingTests } from './pty-connection-terminal-input-gating.spec' +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + describe('pty-connection-kitty-keyboard-restore', registerPtyConnectionKittyKeyboardRestoreTests) describe('pty-connection-mode-2031-subscriptions', registerPtyConnectionMode2031SubscriptionsTests) describe('pty-connection-reattach-mode-reset', registerPtyConnectionReattachModeResetTests) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts index 955d0285bdb..cbf9e258908 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-layout-owner-wake.test.ts @@ -16,10 +16,22 @@ import { installTerminalTestGlobals, restoreTerminalTestGlobals } from './pty-connection-test-environment' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' const mocks = vi.hoisted(() => ({ wake: vi.fn() })) vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) vi.mock('@/store/terminals/terminal-sleep-layout-owner', () => ({ recordPaneWakeThroughLayoutOwner: mocks.wake })) @@ -62,13 +74,15 @@ describe('wake through main in a layout-owner build', () => { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { await restoreTerminalTestGlobals() }) - it('records the wake of the revealed pane alone, once its agent resumed', async () => { + // Main's start of the revealed pane is its wake: the window records none of its own. + it('asks main to start the revealed pane alone, and records no wake itself', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-pane-2') transportFactoryQueue.push(transport) @@ -93,6 +107,10 @@ describe('wake through main in a layout-owner build', () => { mocks.wake.mockImplementation(() => { recordHeldAtWake = paneKey in mockStoreState.sleepingAgentSessionsByPaneKey }) + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) const binding = connectPanePty(...args) await flushAsyncTicks() const onPtyExit = createdTransportOptions[0]?.onPtyExit @@ -106,12 +124,18 @@ describe('wake through main in a layout-owner build', () => { binding.noteVisibilityResume() await flushAsyncTicks() - const resumeConnectOptions: { command?: string } | undefined = - transport.connect.mock.calls.at(-1)?.[0] - expect(resumeConnectOptions?.command).toContain('sess-shown') - expect(mocks.wake).toHaveBeenCalledTimes(1) - expect(mocks.wake).toHaveBeenCalledWith(expect.anything(), 'wt-1', paneKey) - expect(recordHeldAtWake).toBe(true) - expect(mockStoreState.sleepingAgentSessionsByPaneKey).not.toHaveProperty(paneKey) + // The revealed pane alone asks main to start it, then attaches to main's terminal. + await expectWindowStartAskedMain(transport, paneKey, mockStoreState) + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toEqual([ + expect.objectContaining({ paneKey }) + ]) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + expect(mocks.wake).not.toHaveBeenCalled() + expect(recordHeldAtWake).toBeUndefined() + // Main keeps the pane's record; the other pane's record is untouched. + expect(mockStoreState.sleepingAgentSessionsByPaneKey).toHaveProperty(paneKey) + expect(mockStoreState.sleepingAgentSessionsByPaneKey).toHaveProperty(otherPaneKey) }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-pane-restore-start.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-pane-restore-start.test.ts new file mode 100644 index 00000000000..2af3b61bc0f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-pane-restore-start.test.ts @@ -0,0 +1,264 @@ +// Layout-owner build: a pane with no live terminal is started by main, never by the window. A +// sleeping pane asks main on its first view and attaches once main binds it; a reattach that finds +// its terminal gone asks main too, and nothing the window does starts or resumes a terminal. +import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + leafIdForPane, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import type { TerminalPaneStart } from '@/store/terminals/terminal-state' +import type { LayoutApplyHandlers, WindowLayoutCommand } from '@/runtime/layout-apply-send' +import type * as LayoutApplySend from '@/runtime/layout-apply-send' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' +import { PANE_NOT_LIVE_ERROR } from '../../../../shared/pane-attach-only-view' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const sent = vi.hoisted((): { command: WindowLayoutCommand; handlers: LayoutApplyHandlers }[] => []) +vi.mock('@/runtime/layout-apply-send', async (importOriginal) => ({ + ...(await importOriginal()), + sendLayoutApply: ( + _state: unknown, + _worktreeId: string, + command: WindowLayoutCommand, + handlers: LayoutApplyHandlers + ) => { + sent.push({ command, handlers }) + return true + } +})) + +type LayoutApplySendModule = typeof LayoutApplySend + +const TAB = 'tab-restore-start' +const LEAF = leafIdForPane(1) +const PANE_KEY = `${TAB}:${LEAF}` + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +function setStore(patch: Partial): void { + mockStoreState = { ...mockStoreState, ...patch } + for (const listener of storeSubscribers) { + listener(mockStoreState) + } +} + +function layoutBoundTo(ptyId: string): Partial { + return { + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: ptyId } + } + } + } +} + +async function connectBound( + start: TerminalPaneStart | undefined, + visible = true, + transport = createMockTransport() +) { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(transport) + setStore({ + tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: 'pty-old' }] }, + ...layoutBoundTo('pty-old'), + terminalPaneStartsByPaneKey: start ? { [PANE_KEY]: start } : {} + }) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + tabId: TAB, + restoredLeafId: LEAF, + restoredPtyIdByLeafId: { [LEAF]: 'pty-old' }, + isVisibleRef: { current: visible } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [createPane(1), createManager(1), deps] as unknown as Parameters< + typeof connectPanePty + > + const connection = connectPanePty(...args) + await flushAsyncTicks() + return { transport, deps, connection } +} + +/** Main's start: the frame shows the pane starting, its reply lands, then the pane binds. */ +async function mainStartsAndBinds(ptyId: string): Promise { + setStore({ terminalPaneStartsByPaneKey: { [PANE_KEY]: { status: 'resuming' } } }) + sent.at(-1)?.handlers.apply({}) + await flushAsyncTicks() + setStore({ ...layoutBoundTo(ptyId), terminalPaneStartsByPaneKey: {} }) + await flushAsyncTicks() +} + +const startPaneCommands = (): WindowLayoutCommand[] => + sent.map((entry) => entry.command).filter((command) => command.type === 'startPane') + +describe('a restored pane main starts (layout-owner build)', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + sent.length = 0 + transportFactoryQueue = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + bindPtyConnectionTestMocks({ + getState: () => mockStoreState, + getTransportQueue: () => transportFactoryQueue, + getTransportOptions: () => [], + getStoreSubscribers: () => storeSubscribers, + setStoreSubscribers: (subscribers) => { + storeSubscribers = subscribers + } + }) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it("ptyId + sleeping doesn't attach; a later live bind does", async () => { + const { transport } = await connectBound({ status: 'sleeping' }) + + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).not.toHaveBeenCalled() + expect(startPaneCommands()).toEqual([{ type: 'startPane', tabId: TAB, paneKey: PANE_KEY }]) + + await mainStartsAndBinds('pty-new') + + expect(transport.connect).toHaveBeenCalledTimes(1) + const [options] = transport.connect.mock.calls[0] + expect(options).toMatchObject({ sessionId: 'pty-new', attachOnlyView: true }) + // Main resumed the agent; the attach carries no command of its own. + expect(options).not.toHaveProperty('command') + expect(options).not.toHaveProperty('resumeProviderSession') + }) + + it('a hidden sleeping pane asks main only once it is shown', async () => { + const { transport, deps, connection } = await connectBound({ status: 'sleeping' }, false) + expect(startPaneCommands()).toEqual([]) + expect(transport.connect).not.toHaveBeenCalled() + + deps.isVisibleRef.current = true + connection.noteVisibilityResume?.() + await flushAsyncTicks() + + expect(startPaneCommands()).toHaveLength(1) + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('a reattach whose terminal is gone asks main, then attaches to what main binds', async () => { + const transport = createMockTransport() + transport.connect.mockImplementationOnce( + async (options: { callbacks?: { onError?: (message: string) => void } }) => { + options.callbacks?.onError?.( + `Error invoking remote method 'pty:spawn': ${PANE_NOT_LIVE_ERROR}` + ) + return undefined + } + ) + const { deps } = await connectBound(undefined, true, transport) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect.mock.calls[0][0]).toMatchObject({ + sessionId: 'pty-old', + attachOnlyView: true + }) + expect(startPaneCommands()).toEqual([{ type: 'startPane', tabId: TAB, paneKey: PANE_KEY }]) + expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() + + await mainStartsAndBinds('pty-new') + + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect.mock.calls[1][0]).toMatchObject({ + sessionId: 'pty-new', + attachOnlyView: true + }) + expect(transport.connect.mock.calls[1][0]).not.toHaveProperty('command') + }) + + it('asks main once when the terminal main bound is gone too, then shows why', async () => { + const transport = createMockTransport() + transport.connect.mockImplementation( + async (options: { callbacks?: { onError?: (message: string) => void } }) => { + options.callbacks?.onError?.(PANE_NOT_LIVE_ERROR) + return undefined + } + ) + const { deps } = await connectBound(undefined, true, transport) + await mainStartsAndBinds('pty-new') + + expect(startPaneCommands()).toHaveLength(1) + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( + 1, + 'The terminal could not start: terminal_not_started' + ) + }) + + it('a hidden pane whose terminal is gone asks main only once it is shown', async () => { + const transport = createMockTransport() + let answerGone = (): void => {} + transport.connect.mockImplementationOnce( + (options: { callbacks?: { onError?: (message: string) => void } }) => + new Promise((resolve) => { + answerGone = () => { + options.callbacks?.onError?.(PANE_NOT_LIVE_ERROR) + resolve(undefined) + } + }) + ) + const { deps, connection } = await connectBound(undefined, true, transport) + // The pane is hidden by the time its attach finds the terminal gone. + deps.isVisibleRef.current = false + answerGone() + await flushAsyncTicks() + expect(startPaneCommands()).toEqual([]) + + deps.isVisibleRef.current = true + connection.noteVisibilityResume?.() + await flushAsyncTicks() + + expect(startPaneCommands()).toHaveLength(1) + }) + + it('asks main again on the next view after a refused start', async () => { + const { deps, connection } = await connectBound({ status: 'sleeping' }) + expect(startPaneCommands()).toHaveLength(1) + + sent.at(-1)?.handlers.refused?.('call_failed') + expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith( + 1, + 'The terminal could not start: call_failed' + ) + connection.noteVisibilityResume?.() + await flushAsyncTicks() + + expect(startPaneCommands()).toHaveLength(2) + }) + + it('a twin main hands back starts as today from then on', async () => { + const { transport } = await connectBound({ status: 'sleeping' }) + sent.at(-1)?.handlers.asToday() + await flushAsyncTicks() + + expect(startPaneCommands()).toHaveLength(1) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect.mock.calls[0][0]).not.toHaveProperty('attachOnlyView') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts index dda99584fbb..1ff5b2fdeaa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts @@ -18,8 +18,20 @@ import { installTerminalTestGlobals, restoreTerminalTestGlobals } from './pty-connection-test-environment' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) const TAB = 'tab-main-start' const PANE_KEY = `${TAB}:${leafIdForPane(1)}` @@ -35,6 +47,17 @@ function setStore(patch: Partial): void { } } +/** Main's frame binding the pane to `ptyId`, as the store shows it. */ +function bindLeaf1(ptyId: string): void { + bindsPaneInStore(() => mockStoreState, TAB, leafIdForPane(1))(ptyId) +} + +/** Main's start: the frame binds the pane and clears its start status, as main's does. */ +function startLeaf1(ptyId: string): void { + bindLeaf1(ptyId) + mockStoreState = { ...mockStoreState, terminalPaneStartsByPaneKey: {} } +} + async function connectWithStart(start: TerminalPaneStart | undefined) { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -70,6 +93,7 @@ describe('a pane main is starting a terminal for', () => { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -165,14 +189,39 @@ describe('a pane main is starting a terminal for', () => { ) }) - it.each(['sleeping', 'resuming', 'held'] as const)( - 'leaves a %s pane to its restore path, as before', - async (status) => { - const { transport } = await connectWithStart({ status }) + it('asks main to start a sleeping pane, then attaches to the terminal main binds', async () => { + await mainStartsPaneAs({ ptyId: 'pty-main', bind: startLeaf1 }) + const { transport } = await connectWithStart({ status: 'sleeping' }) - expect(transport.connect.mock.calls.length + transport.attach.mock.calls.length).toBe(1) - } - ) + await expectWindowStartAskedMain(transport, PANE_KEY) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + }) + + it('waits on a resuming pane, then attaches to the terminal main binds', async () => { + const { transport } = await connectWithStart({ status: 'resuming' }) + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).not.toHaveBeenCalled() + + bindLeaf1('pty-main') + setStore({ terminalPaneStartsByPaneKey: {} }) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toEqual([]) + }) + + it("connects a held pane as today, so main's refusal holds it", async () => { + const { transport } = await connectWithStart({ status: 'held' }) + + expect(transport.connect.mock.calls.length + transport.attach.mock.calls.length).toBe(1) + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toEqual([]) + }) it('starts a pane with no start from main as before', async () => { const { transport } = await connectWithStart(undefined) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts index f5df40f3df0..164c2a9f21b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts @@ -4,6 +4,12 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { sendTerminalInputThroughPane } from './pty-connection-test-dom' import { flushAsyncTicks } from './pty-connection-test-async' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { expectMainFrameClosesExitedSplitPaneOnce } from './pty-connection-test-exited-split-pane-close' import { LEAF_1, @@ -82,6 +88,7 @@ export function registerPtyConnectionPtyExitTeardownTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -529,16 +536,23 @@ export function registerPtyConnectionPtyExitTeardownTests(): void { transport.connect.mockImplementation( async (options: { sessionId?: string; callbacks: ConnectCallbacks }) => { callbacks.push(options.callbacks) - if (options.sessionId) { + if (options.sessionId === 'lost-pty') { options.callbacks.onData?.('too many consoles in use, max consoles is 128') return { id: currentPtyId, sessionExpired: true } } - currentPtyId = 'resume-pty' - return currentPtyId + // Switch on, the replacement is main's start, which the window attaches to. + currentPtyId = options.sessionId ?? 'resume-pty' + return options.sessionId ? { id: currentPtyId } : currentPtyId } ) transportFactoryQueue.push(transport) installSleepingCodexResumeState('lost-pty') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_1) + }) + } const deps = createDeps({ onPaneProcessDied: vi.fn(), restoredLeafId: LEAF_1, @@ -548,10 +562,14 @@ export function registerPtyConnectionPtyExitTeardownTests(): void { connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) await flushAsyncTicks(30) expect(callbacks).toHaveLength(2) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectWindowStartAskedMain(transport, makePaneKey('tab-1', LEAF_1)) + expect(currentPtyId).toBe('pty-main') + } const onPtyExit = createdTransportOptions[0]?.onPtyExit as | ((ptyId: string, exitCode?: number) => void) | undefined - onPtyExit?.('resume-pty', 1) + onPtyExit?.(currentPtyId, 1) expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ paneId: 1, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts index 47d3881aaa2..abfe7d3e3ab 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts @@ -2,6 +2,13 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getEagerPtyBufferHandle } from './pty-dispatcher' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { LEAF_1, LEAF_2, @@ -40,6 +47,13 @@ let transportFactoryQueue: MockTransport[] = [] let createdTransportOptions: Record[] = [] let storeSubscribers: ((state: StoreState) => void)[] = [] +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) @@ -139,6 +153,7 @@ describe('connectPanePty', () => { storeSubscribers = [] mockStoreState = createInitialStoreState(() => mockStoreState) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -696,6 +711,13 @@ describe('connectPanePty', () => { restoredLeafId: LEAF_2, restoredPtyIdByLeafId: { [LEAF_2]: 'stale-pty' } }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_2), + transport + }) + } connectPanePty(pane as never, manager as never, deps as never) await Promise.resolve() @@ -705,6 +727,17 @@ describe('connectPanePty', () => { 1, expect.objectContaining({ sessionId: 'stale-pty' }) ) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main starts the pane whose terminal is gone; the window attaches, and spawns no shell. + await flushAsyncTicks() + await expectWindowStartAskedMain(transport, `tab-1:${LEAF_2}`) + expect(transport.connect).toHaveBeenCalledTimes(2) + expect(transport.connect).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + return + } expect(transport.connect).toHaveBeenNthCalledWith( 2, expect.not.objectContaining({ sessionId: expect.any(String) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts index 6e9888f4fad..cc784b26142 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts @@ -12,6 +12,13 @@ import { import { replayEpilogue } from './pty-connection-test-replay-epilogue' import { STARTUP_CWD_FALLBACK_NOTICE } from '../../../../shared/startup-cwd-fallback' import { flushAsyncTicks } from './pty-connection-test-async' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { withMockedDocumentActiveElement, configureTerminalFocusMode, @@ -74,6 +81,7 @@ export function registerPtyConnectionReattachModeResetTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -417,10 +425,11 @@ export function registerPtyConnectionReattachModeResetTests(): void { let activePtyId = 'tab-pty' transport.getPtyId.mockImplementation(() => activePtyId) transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { + if (sessionId === 'tab-pty') { throw new Error('restored session is gone') } - // Main answered the spawn by adopting a durable session instead. + // Main answered the spawn by adopting a durable session instead; switch on, main's start + // bound the pane to it and the window's attach is answered so. activePtyId = 'adopted-pty' return { id: 'adopted-pty', @@ -444,13 +453,28 @@ export function registerPtyConnectionReattachModeResetTests(): void { restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'adopted-pty', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_1) + }) + } connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(30) expect(transport.connect).toHaveBeenCalledTimes(2) expect(transport.connect.mock.calls[0]?.[0]?.sessionId).toBe('tab-pty') - expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The gone session asks main to start the pane; the attach to it meets the payload. + await expectWindowStartAskedMain(transport, `tab-1:${LEAF_1}`) + expect(transport.connect.mock.calls[1]?.[0]).toMatchObject({ + sessionId: 'adopted-pty', + attachOnlyView: true + }) + } else { + expect(transport.connect.mock.calls[1]?.[0]?.sessionId).toBeUndefined() + } const writes = (pane.terminal.write as ReturnType).mock.calls.map( ([data]) => data as string ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts index e3b85402139..4b18aa93773 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-restart-replacement.spec.ts @@ -1,6 +1,13 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushAsyncTicks } from './pty-connection-test-async' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { leafIdForPane, createMockTransport, @@ -44,6 +51,7 @@ export function registerPtyConnectionRestartReplacementTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -73,6 +81,11 @@ export function registerPtyConnectionRestartReplacementTests(): void { updatedAt: 1, origin: 'live' } + // Switch on: main's start of the replacement binds the pane to `pty-main`. + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', leafIdForPane(2)) + }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. const args = [pane, createManager(1), deps] as unknown as Parameters const binding = connectPanePty(...args) @@ -94,7 +107,19 @@ export function registerPtyConnectionRestartReplacementTests(): void { const connects = (): number => transport.connect.mock.calls.length const lastCommand = (): string | undefined => transport.connect.mock.calls.at(-1)?.[0]?.command - return { binding, exit, connects, lastCommand } + /** The replacement resumed the agent: the window's own spawn, or, switch on, main's start. */ + const expectResumed = async (): Promise => { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(lastCommand()).toContain('sess-1') + return + } + await expectWindowStartAskedMain(transport, paneKey, mockStoreState) + expect(transport.connect).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + expect((await mainLayoutApplyTransport()).sentOfType('startPane')).toHaveLength(1) + } + return { binding, exit, connects, lastCommand, expectResumed } } it('resumes a restart-replaced hidden pane once, when it is next shown', async () => { @@ -107,7 +132,7 @@ export function registerPtyConnectionRestartReplacementTests(): void { pane.binding.noteVisibilityResume() await flushAsyncTicks() expect(pane.connects()).toBe(before + 1) - expect(pane.lastCommand()).toContain('sess-1') + await pane.expectResumed() pane.binding.noteVisibilityResume() await flushAsyncTicks() @@ -121,7 +146,7 @@ export function registerPtyConnectionRestartReplacementTests(): void { await pane.exit(true) expect(pane.connects()).toBe(before + 1) - expect(pane.lastCommand()).toContain('sess-1') + await pane.expectResumed() }) it('spawns nothing for a restart-replaced pane closed before it is shown', async () => { @@ -168,7 +193,7 @@ export function registerPtyConnectionRestartReplacementTests(): void { await flushAsyncTicks() expect(pane.connects()).toBe(before + 1) - expect(pane.lastCommand()).toContain('sess-1') + await pane.expectResumed() }) it('leaves a pane lost to any other -1 exit without a process', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts index 188f3ae980c..2766e6b15c1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts @@ -1,6 +1,13 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + bindsPaneInStore, + expectWindowStartAskedMain, + mainLayoutApplyTransport, + mainStartsPaneAs +} from './pty-connection-test-main-pane-start-fixtures' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' import { @@ -46,6 +53,7 @@ export function registerPtyConnectionSleepingResumeBannerTests(): void { } }) await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -101,10 +109,30 @@ export function registerPtyConnectionSleepingResumeBannerTests(): void { restoredLeafId: LEAF_2, restoredPtyIdByLeafId: {} }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_2), + transport + }) + } connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(4) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main starts the pane from its record; the window attaches, keeps the record, and shows + // no restored banner of its own. + await expectWindowStartAskedMain(transport, paneKey, mockStoreState) + await flushAsyncTicks(10) + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty-main', attachOnlyView: true }) + ) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + return + } expect(transport.connect).toHaveBeenCalledTimes(1) expect(transport.connect).toHaveBeenCalledWith( expect.objectContaining({ @@ -207,10 +235,25 @@ export function registerPtyConnectionSleepingResumeBannerTests(): void { restoredLeafId: LEAF_2, restoredPtyIdByLeafId: {} }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_2), + transport + }) + } connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(4) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main starts the pane and decides its resume; the window attaches. Ledger L251: main does + // not yet publish a declined resume, so the window shows no banner (a known switch-on loss). + await expectWindowStartAskedMain(transport, paneKey, mockStoreState) + await flushAsyncTicks(10) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + return + } expect(transport.connect).toHaveBeenCalledWith( expect.not.objectContaining({ sessionId: expect.any(String) }) ) @@ -281,11 +324,25 @@ export function registerPtyConnectionSleepingResumeBannerTests(): void { isVisibleRef }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await mainStartsPaneAs({ + ptyId: 'pty-main', + bind: bindsPaneInStore(() => mockStoreState, 'tab-1', LEAF_2), + transport + }) + } const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { noteVisibilityResume: () => void } await flushAsyncTicks(10) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main starts and resumes the pane; the window attaches and claims no restore of its own. + // Ledger L251: main does not yet publish its resume outcome, so neither banner shows. + await expectWindowStartAskedMain(transport, paneKey, mockStoreState) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + return + } expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored') isVisibleRef.current = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts index 43024613276..5431c1af990 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts @@ -1,6 +1,11 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { + expectWindowStartAskedMain, + mainLayoutApplyTransport +} from './pty-connection-test-main-pane-start-fixtures' import { flushAsyncTicks } from './pty-connection-test-async' import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures' import { buildPaneConnectionDeps } from './pty-connection-test-deps' @@ -33,6 +38,13 @@ let transportFactoryQueue: MockTransport[] = [] let createdTransportOptions: Record[] = [] let storeSubscribers: ((state: StoreState) => void)[] = [] +// Main's answer to the window's layout commands, as a switch-on build sends them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) @@ -162,6 +174,23 @@ function freshSpawns(transport: MockTransport): Record[] { }) } +/** + * Switch on: the restore's reattach found its terminal gone, so the window asked main to start the + * pane. Main's start meets the host's cap, not the window: the window spawned nothing and holds no + * deferral or retry of its own. + */ +async function expectRestoreAskedMainNotDeferred( + transport: MockTransport, + paneKey: string +): Promise { + const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry') + // The pane's own launch recipe still registers at mount; what main starts is main's. + await expectWindowStartAskedMain(transport, paneKey) + expect(freshSpawns(transport)).toEqual([]) + expect(getPaneSpawnDeferral(paneKey)).toBeUndefined() + expect(drain.queued.has(paneKey)).toBe(false) +} + function deferringTransport(reason = 'live_cap'): MockTransport { const transport = createMockTransport(null) transport.connect.mockImplementation( @@ -194,6 +223,7 @@ describe('connectPanePty: a restore the host kept asleep', () => { mockStoreState.pendingStartupByTabId = {} mockStoreState.automaticAgentResumeClaimsByTabId = {} await installTerminalTestGlobals() + ;(await mainLayoutApplyTransport()).reset() }) afterEach(async () => { @@ -220,6 +250,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } expect(freshSpawns(transport)).toEqual([ expect.objectContaining({ resumeSpawnIntent: 'automatic-restore' }) @@ -264,6 +299,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } const retry = drain.queued.get(paneKey) expect(retry).toBeTypeOf('function') @@ -300,6 +340,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } expect(drain.queued.has(paneKey)).toBe(true) binding.dispose() expect(drain.queued.has(paneKey)).toBe(false) @@ -309,7 +354,8 @@ describe('connectPanePty: a restore the host kept asleep', () => { const { connectPanePty } = await import('./pty-connection') const { getPaneSpawnDeferral } = await import('@/lib/pane-spawn-deferral-registry') const { requestTerminalPaneRecovery } = await import('./terminal-pane-recovery') - transportFactoryQueue.push(deferringTransport()) + const transport = deferringTransport() + transportFactoryQueue.push(transport) const deps = createDeps({ isVisibleRef: { current: false }, startup: { @@ -324,6 +370,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } expect(getPaneSpawnDeferral(paneKey)?.reason).toBe('live_cap') expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() @@ -350,6 +401,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } expect(getPaneSpawnDeferral(paneKey)).toBeDefined() const otherLeaf = '22222222-2222-4222-8222-222222222222' @@ -400,6 +456,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } let bind: (ptyId: string) => void = () => {} transport.connect.mockImplementation( @@ -437,6 +498,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } mockStoreState.pendingStartupByTabId = { 'tab-9': { command: 'claude', launchAgent: 'claude', resumeProviderSession: RESUME } } @@ -467,6 +533,11 @@ describe('connectPanePty: a restore the host kept asleep', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures stand in for the xterm-backed pane, manager and deps connectPanePty reads, as in the sibling connectPanePty specs. const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(20) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectRestoreAskedMainNotDeferred(transport, makePaneKey('tab-1', pane.leafId)) + binding.dispose() + return + } expect(canResumeHostAutomaticRestores).toHaveBeenCalledWith(null) // A crash pause waits for the user; nothing retries it in the background. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-main-pane-start-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-main-pane-start-fixtures.ts new file mode 100644 index 00000000000..09747502feb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-main-pane-start-fixtures.ts @@ -0,0 +1,134 @@ +import { expect } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { MockTransport } from './pty-connection-test-pane-fixtures' +import type { layoutApplyTestTransport as answeringFallback } from '@/runtime/layout-apply-test-transport.test-fixture' + +type ConnectOptions = { + sessionId?: string + attachOnlyView?: true + command?: string + resumeProviderSession?: unknown +} + +/** + * Switch on: the window's own start of the pane (a cold-restore resume, a hibernated or deferred + * wake, a restart replacement) asked main through `startPane`, and the window started nothing: no + * connect carries a command or a resume, and every attach is attach-only. + */ +export async function expectWindowStartAskedMain( + transport: MockTransport, + paneKey: string, + store?: ResumeRegistry +): Promise { + const layoutApplyTestTransport = await mainLayoutApplyTransport() + await layoutApplyTestTransport.settle() + expect(layoutApplyTestTransport.sentOfType('startPane')).toContainEqual( + expect.objectContaining({ type: 'startPane', paneKey }) + ) + expectNoWindowStart(transport, store) +} + +type ResumeRegistry = { registerAgentLaunchConfig: unknown } + +/** + * Switch on: no connect the window made started or resumed a terminal of its own, and, given the + * store, the window registered no resume launch of its own. + */ +export function expectNoWindowStart(transport: MockTransport, store?: ResumeRegistry): void { + if (store) { + expect(store.registerAgentLaunchConfig).not.toHaveBeenCalled() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock transport's connect takes one options argument. + for (const [options] of transport.connect.mock.calls as [ConnectOptions | undefined][]) { + expect(options?.command).toBeUndefined() + expect(options?.resumeProviderSession).toBeUndefined() + if (options?.sessionId) { + expect(options.attachOnlyView).toBe(true) + } + } +} + +type LayoutApplyTestTransport = typeof answeringFallback + +/** The transport the mocked RPC client answers the window's layout commands with, even after + * `vi.resetModules`; dynamic, as the mocked client is. */ +export async function mainLayoutApplyTransport(): Promise { + const { ANSWERING_TRANSPORT, layoutApplyTestTransport } = + await import('@/runtime/layout-apply-test-transport.test-fixture') + const client: object = await import('@/runtime/runtime-rpc-client') + const answering = ANSWERING_TRANSPORT in client ? client[ANSWERING_TRANSPORT] : undefined + return isLayoutApplyTestTransport(answering) ? answering : layoutApplyTestTransport +} + +function isLayoutApplyTestTransport(value: unknown): value is LayoutApplyTestTransport { + return typeof value === 'object' && value !== null && 'sentOfType' in value && 'reset' in value +} + +/** + * Main's answer to the window's `startPane`: it binds the pane to `ptyId` (the store's layout, as + * main's frame does) and the terminal answers an attach to that id. Call before the window asks. + */ +export async function mainStartsPaneAs(args: { + ptyId: string + bind: (ptyId: string) => void + transport?: MockTransport +}): Promise { + const layoutApplyTestTransport = await mainLayoutApplyTransport() + layoutApplyTestTransport.answerWith((command) => { + if (command.type === 'startPane') { + args.bind(args.ptyId) + args.transport?.connect.mockImplementation(async (options: { sessionId?: string }) => + options.sessionId === args.ptyId ? { id: args.ptyId, isReattach: true } : undefined + ) + } + return { ok: true, result: {} } + }) +} + +/** + * Switch on: a reattach answered with a cold restore. The window attached attach-only, painted the + * host's history, and resumed nothing: no command, no launch registered, no restored banner (main's + * start owns the resume and its record), and no record dropped. + */ +export function expectWindowPaintsColdRestoreOnly( + pane: { terminal: { write: { mock: { calls: unknown[][] } } } }, + transport: MockTransport, + deps: { onShowSessionRestoredBanner: unknown }, + store: ResumeRegistry & { clearSleepingAgentSession: unknown }, + ptyId: string +): void { + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: ptyId, attachOnlyView: true }) + ) + expect( + pane.terminal.write.mock.calls.some( + ([data]) => typeof data === 'string' && data.endsWith('cold-payload') + ) + ).toBe(true) + expectNoWindowStart(transport, store) + expect(transport.sendInput).not.toHaveBeenCalled() + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + expect(store.clearSleepingAgentSession).not.toHaveBeenCalled() +} + +type LayoutsHolder = { terminalLayoutsByTabId?: Record } + +/** Main's frame binding one pane: writes the binding into the test store's layout. */ +export function bindsPaneInStore( + getState: () => LayoutsHolder, + tabId: string, + leafId: string +): (ptyId: string) => void { + return (ptyId) => { + const state = getState() + const layout = state.terminalLayoutsByTabId?.[tabId] ?? { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null + } + state.terminalLayoutsByTabId = { + ...state.terminalLayoutsByTabId, + [tabId]: { ...layout, ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } } + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index b5710158644..139f80007af 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -1,4 +1,3 @@ -import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { POST_REPLAY_MODE_RESET, @@ -14,6 +13,8 @@ import { } from '../terminal-snapshot-replay-paint' import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' +import { ackPaintedColdRestore } from './painted-cold-restore-ack' +import { mainStartsPane } from './main-starts-pane' import { fitReattachedPaneToGrid, noteReattachAltFrameSkip } from './reattach-grid-fit' import type { ReattachPayloadContext } from './reattach-payload-context' @@ -115,9 +116,7 @@ export function createReattachPayloadHandlers( session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) if (ctx.connectResult.coldRestore) { // Snapshot superseded the cold-restore payload; ack so the daemon doesn't redeliver it. - if (!isRemoteRuntimePtyId(ctx.ptyId)) { - window.api.pty.ackColdRestore(ctx.ptyId) - } + ackPaintedColdRestore(session, ctx.ptyId) } } else if (ctx.connectResult?.replay || ctx.prefetchedParkModelSnapshot) { // Why scoped to a park-reveal: an in-place reattach (network reconnect, @@ -185,8 +184,8 @@ export function createReattachPayloadHandlers( ) session.recordRendererOrderedSeq(modelSnapshot) session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) - if (ctx.connectResult?.coldRestore && !isRemoteRuntimePtyId(ctx.ptyId)) { - window.api.pty.ackColdRestore(ctx.ptyId) + if (ctx.connectResult?.coldRestore) { + ackPaintedColdRestore(session, ctx.ptyId) } } else if (ctx.connectResult?.replay) { session.rememberReattachPayloadAgentSignal(ctx.connectResult.replay, { @@ -215,9 +214,7 @@ export function createReattachPayloadHandlers( ) session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) if (ctx.connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ctx.ptyId)) { - window.api.pty.ackColdRestore(ctx.ptyId) - } + ackPaintedColdRestore(session, ctx.ptyId) } } } else if (ctx.connectResult?.coldRestore) { @@ -263,7 +260,10 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - const preparedStartup = ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup() + // Why: main started the pane and its agent; the window only paints it. + const preparedStartup = mainStartsPane(session) + ? null + : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) if (didPrepareResume) { if (ctx.connectResult.agentResumeUnavailable) { @@ -280,9 +280,7 @@ export function createReattachPayloadHandlers( session.consumeRestoredViewportBlankingMarker() // Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay. session.writeFreshShellViewportBlanking(Math.max(destinationRows, session.pane.terminal.rows)) - if (!isRemoteRuntimePtyId(ctx.ptyId)) { - window.api.pty.ackColdRestore(ctx.ptyId) - } + ackPaintedColdRestore(session, ctx.ptyId) if (didPrepareResume && !ctx.coldRestoreStartup) { session.schedulePendingStartupCommandDelivery() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 3e7469b6092..b3a2f033ad7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -21,6 +21,7 @@ import { bindStartFreshSpawn } from './fresh-spawn-start' import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset' import { deferHiddenAgentResume } from './hidden-agent-resume-deferral' import { holdInPlaceResumeUntilSettled } from './in-place-resume-hold' +import { startPaneThroughMainThenAttach } from './main-pane-start-request' export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySession): void { session.applyColdRestoreAgentResumeStartup = ( @@ -73,15 +74,16 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio session.spawnColdRestoreAgentResume = ( startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} - ): Promise => { - session.applyColdRestoreAgentResumeStartup(startup) - const spawned = session.startFreshSpawn(startup, options) - // Switch point: main's layout shows this pane unbound while the start runs. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - holdInPlaceResumeUntilSettled(() => !session.disposed, startup, spawned) - } - return spawned - } + ): Promise => + startPaneThroughMainThenAttach(session, () => { + session.applyColdRestoreAgentResumeStartup(startup) + const spawned = session.startFreshSpawn(startup, options) + // Switch point: main's layout shows this pane unbound while the start runs. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + holdInPlaceResumeUntilSettled(() => !session.disposed, startup, spawned) + } + return spawned + }) session.startFreshColdRestoreAgentResume = ( startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index e7246af3373..18c00f2a2f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -1,6 +1,4 @@ -import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' -import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { writeStartupCwdFallbackNotice } from './startup-cwd-fallback-notice' import { armPaneOwnerRetry } from './pane-owner-recovery' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics' @@ -10,8 +8,10 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' import type { HeldPaneReopenOutcome } from '../newer-daemon-hold-state' import { holdForNewerDaemon } from './newer-daemon-hold-pane' import { NEWER_DAEMON_HOLD_ERROR } from '../../../../../shared/newer-daemon-hold' +import { PANE_NOT_LIVE_ERROR } from '../../../../../shared/pane-attach-only-view' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import { holdInPlaceResumeUntilSettled } from './in-place-resume-hold' +import { mainStartsPane, startMainPaneOnFirstView } from './main-starts-pane' import { awaitAgentStatusStartupSnapshot, @@ -21,11 +21,15 @@ import { const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified' const PANE_OWNER_CHANGED_ERROR = 'terminal_pane_owner_changed' +/** Layout-owner build: `afterMainStart` attaches to the terminal main just started for the pane. */ +type ReattachOptions = { afterMainStart?: true } + export function startDeferredSessionReattach( session: ConnectPanePtySession, - deferredReattachSessionId: string + deferredReattachSessionId: string, + options: ReattachOptions = {} ): void { - void prepareDeferredSessionReattach(session, deferredReattachSessionId).catch( + void prepareDeferredSessionReattach(session, deferredReattachSessionId, options).catch( (error: unknown) => { if (!session.disposed) { session.reportError(error instanceof Error ? error.message : String(error)) @@ -36,7 +40,8 @@ export function startDeferredSessionReattach( async function prepareDeferredSessionReattach( session: ConnectPanePtySession, - deferredReattachSessionId: string + deferredReattachSessionId: string, + options: ReattachOptions ): Promise { const generation = session.transportStreamGeneration let waitedForSnapshot = false @@ -56,7 +61,7 @@ async function prepareDeferredSessionReattach( ) { return } - void connectDeferredSessionReattach(session, deferredReattachSessionId) + void connectDeferredSessionReattach(session, deferredReattachSessionId, options) } const NOT_REOPENED: HeldPaneReopenOutcome = { reopened: false, agentResumed: false } @@ -64,8 +69,12 @@ const NOT_REOPENED: HeldPaneReopenOutcome = { reopened: false, agentResumed: fal /** Resolves to what the reattach did, which a held pane's release reports. */ function connectDeferredSessionReattach( session: ConnectPanePtySession, - deferredReattachSessionId: string + deferredReattachSessionId: string, + options: ReattachOptions = {} ): Promise { + // Why: while this attach runs, a fallback that would ask main to start again is refused instead. + const mainStartAttach = options.afterMainStart ? {} : null + session.mainStartAttach = mainStartAttach session.allowInitialIdleCacheSeed = true recordPtyConnectDiagnostic(`pane=${session.pane.id} -> REATTACH ${deferredReattachSessionId}`) @@ -77,16 +86,24 @@ function connectDeferredSessionReattach( : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) let expiredReattachError = false + let paneNotLive = false let paneOwnerUnverified = false let paneOwnerChanged = false let heldByNewerDaemon = false - const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup() + // Why: main started this pane's agent; the window's attach never resumes it a second time. + const attachOnlyView = mainStartsPane(session) + const resumeStartup = session.buildColdRestoreAgentResumeStartup() + const coldRestoreStartup = attachOnlyView ? null : resumeStartup const outputCallbacks = session.captureTransportOutputCallbacks( (message) => { if (isSshSessionGoneError(message)) { expiredReattachError = true return } + if (message.includes(PANE_NOT_LIVE_ERROR)) { + paneNotLive = true + return + } // Why local only: a paired host refuses for its own daemon, which this window cannot stop. if (!session.runtimeEnvironmentId && message.includes(NEWER_DAEMON_HOLD_ERROR)) { heldByNewerDaemon = true @@ -100,7 +117,8 @@ function connectDeferredSessionReattach( } session.reportError(message) }, - toProcessExitStartup(coldRestoreStartup ?? session.paneStartup) + // Why the resume even under main: it is what main ran, so a capacity retry resumes it again. + toProcessExitStartup(resumeStartup ?? session.paneStartup) ) session.beginReattachLiveDataDeferral(outputCallbacks.generation) session.transportConnectInFlightSince = Date.now() @@ -120,6 +138,7 @@ function connectDeferredSessionReattach( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(attachOnlyView ? { attachOnlyView: true as const } : {}), callbacks: outputCallbacks.callbacks }) const isCurrentReattach = (): boolean => @@ -189,6 +208,22 @@ function connectDeferredSessionReattach( session.settlePaneAttachAttempt('failed') return NOT_REOPENED } + if (!result && paneNotLive) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + if (!isCurrentReattach()) { + return NOT_REOPENED + } + if (!session.deps.isVisibleRef.current) { + startMainPaneOnFirstView(session, () => void startFreshInstead()) + return NOT_REOPENED + } + // Why the binding stays: main's start reuses its id, which carries the terminal's history. + return startFreshInstead() + } if (!result && expiredReattachError) { session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise @@ -208,16 +243,8 @@ function connectDeferredSessionReattach( coldRestoreStartup, outputCallbacks.generation ) - if ( - accepted && - isCurrentReattach() && - result && - typeof result === 'object' && - result.startupCwdFallback - ) { - writeTerminalOutput(session.pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, { - foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) - }) + if (accepted && isCurrentReattach()) { + writeStartupCwdFallbackNotice(session, result) } session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise @@ -280,6 +307,11 @@ function connectDeferredSessionReattach( session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId) return startFreshInstead() }) + void outcome.finally(() => { + if (session.mainStartAttach === mainStartAttach) { + session.mainStartAttach = null + } + }) // Switch point: main clears the gone terminal's binding before this reattach's fresh start. if (ORCA_LAYOUT_OWNER_IS_MAIN) { holdInPlaceResumeUntilSettled(() => !session.disposed, coldRestoreStartup, outcome) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts index 2a1aca88485..3d1265f79ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts @@ -6,6 +6,7 @@ import { registerAgentStatusStartupSnapshot } from '@/hooks/ipc-events/agent-sta import { NEWER_DAEMON_HOLD_ERROR } from '../../../../../shared/newer-daemon-hold' import { newerDaemonHoldStore, reopenHeldTerminals } from '../newer-daemon-hold-state' import { startDeferredSessionReattach } from './deferred-session-reattach-connect' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import type { ConnectPanePtySession } from './connect-pane-pty-session' vi.mock('sonner', () => ({ toast: { success: vi.fn() } })) @@ -70,6 +71,11 @@ function createSession(opts: { runtimeEnvironmentId?: string } = {}) { return { fixture, session, transport, nextConnect: (mode: typeof next) => (next = mode) } } +/** Switch on, main's start resumes the agent, so the window's reattach claims no resume. */ +const windowResumedDescription = ORCA_LAYOUT_OWNER_IS_MAIN + ? undefined + : { description: '1 agent session resumed.' } + const isHeld = (): boolean => Object.keys(newerDaemonHoldStore.getState().panes).length > 0 describe('restored pane refused for a newer daemon', () => { @@ -109,12 +115,14 @@ describe('restored pane refused for a newer daemon', () => { nextConnect('reattach') await reopenHeldTerminals('tab:leaf') expect(transport.connect).toHaveBeenLastCalledWith( - expect.objectContaining({ sessionId: 'restored-pty', command: 'claude --resume c1' }) + expect.objectContaining( + ORCA_LAYOUT_OWNER_IS_MAIN + ? { sessionId: 'restored-pty', attachOnlyView: true } + : { sessionId: 'restored-pty', command: 'claude --resume c1' } + ) ) expect(fixture.pane.terminal.options.disableStdin).toBe(false) - expect(toast.success).toHaveBeenCalledWith('Reopened 1 terminal', { - description: '1 agent session resumed.' - }) + expect(toast.success).toHaveBeenCalledWith('Reopened 1 terminal', windowResumedDescription) }) it('shows the saved screen while held and clears it before reconnecting', async () => { @@ -149,9 +157,7 @@ describe('restored pane refused for a newer daemon', () => { nextConnect('throw') await reopenHeldTerminals('tab:leaf') expect(fixture.startFreshColdRestoreAgentResume).toHaveBeenCalledOnce() - expect(toast.success).toHaveBeenCalledWith('Reopened 1 terminal', { - description: '1 agent session resumed.' - }) + expect(toast.success).toHaveBeenCalledWith('Reopened 1 terminal', windowResumedDescription) }) it('treats a paired host refusal as an ordinary error, never a local hold', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-startup-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-startup-snapshot.test.ts index e3bcf18dbd2..d2c2348b0cb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-startup-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-startup-snapshot.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { registerAgentStatusStartupSnapshot } from '@/hooks/ipc-events/agent-status-startup-snapshot' import { startDeferredSessionReattach } from './deferred-session-reattach-connect' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import type { ConnectPanePtySession } from './connect-pane-pty-session' vi.mock('../terminal-lifecycle-diagnostics', () => ({ warnTerminalLifecycleAnomaly: vi.fn() })) @@ -82,6 +83,21 @@ describe('cold reattach waits for host identity replay', () => { bridge.settle() await vi.advanceTimersByTimeAsync(0) expect(transport.connect).toHaveBeenCalledOnce() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main's start resumes the conversation; the window's reattach is a view. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + attachOnlyView: true, + initiallyHidden: true + }) + ) + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ command: expect.anything() }) + ) + bridge.dispose() + return + } expect(transport.connect).toHaveBeenCalledWith( expect.objectContaining({ command: "cursor-agent '--resume' 'conversation-742'", diff --git a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts index 086cfb82081..3c44157a66e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - hasEmptyReattachRetireEvidence, - isHibernationDoneRecord -} from './empty-reattach-retire-evidence' +import { hasEmptyReattachRetireEvidence } from './empty-reattach-retire-evidence' +import { isHibernationDoneRecord } from '../../../../../shared/hibernation-done-record' import type { SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts index d1f12a95cb8..f5c1960f48d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts @@ -1,15 +1,7 @@ import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' -import { - isPaneSleepRecord, - type SleepingAgentSessionRecord -} from '../../../../../shared/agent-session-resume' +import { isHibernationDoneRecord } from '../../../../../shared/hibernation-done-record' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' -// Why not activationTreatsNoteAsFinished: a live+done note is a finished turn's idle anchor for a running pane, not sleep. -export function isHibernationDoneRecord(record: SleepingAgentSessionRecord): boolean { - return isPaneSleepRecord(record) && record.state === 'done' -} - // Why: never remote, where disconnect() only closes this viewer's stream, so the retry re-lands on the same live PTY and loops. export function hasEmptyReattachRetireEvidence( ptyId: string, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/in-place-resume-hold-wiring.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/in-place-resume-hold-wiring.test.ts index 4656e63c60a..be78f77453f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/in-place-resume-hold-wiring.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/in-place-resume-hold-wiring.test.ts @@ -36,14 +36,17 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { return { promise, resolve } } -function createSession(connect: () => Promise) { +/** A paired server's pane, which the window still starts itself; main starts every other one. */ +const PAIRED_SERVER = 'env-paired' + +function createSession(connect: () => Promise, runtimeEnvironmentId: string | null) { const transport = { connect: vi.fn(connect), getPtyId: () => null } const fixture = { pane: { id: 1, leafId: 'leaf' }, transport, disposed: false, transportStreamGeneration: 1, - runtimeEnvironmentId: null, + runtimeEnvironmentId, deps: { tabId: 'tab', worktreeId: 'wt', @@ -82,7 +85,7 @@ describe('a start that resumes a record marks its pane as resuming in place (lay it('the reattach that carries the resume holds the mark until it settles', async () => { const connected = deferred() - const session = createSession(() => connected.promise) + const session = createSession(() => connected.promise, PAIRED_SERVER) startDeferredSessionReattach(session, 'restored-pty') await vi.waitFor(() => expect(session.transport.connect).toHaveBeenCalled()) @@ -93,7 +96,7 @@ describe('a start that resumes a record marks its pane as resuming in place (lay }) it('the fresh start that resumes the record holds the mark until it settles', async () => { - const session = createSession(() => Promise.resolve(null)) + const session = createSession(() => Promise.resolve(null), PAIRED_SERVER) bindDeferredColdRestoreAndSnapshot(session) const spawned = deferred() session.startFreshSpawn = vi.fn(() => spawned.promise) @@ -105,4 +108,22 @@ describe('a start that resumes a record marks its pane as resuming in place (lay await start await vi.waitFor(() => expect(isPaneResumingInPlace(RECORD_PANE_KEY)).toBe(false)) }) + it('a pane main starts: its reattach is a view that carries no resume and holds no mark', async () => { + const connected = deferred() + const session = createSession(() => connected.promise, null) + + startDeferredSessionReattach(session, 'restored-pty') + await vi.waitFor(() => expect(session.transport.connect).toHaveBeenCalled()) + expect(session.transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'restored-pty', attachOnlyView: true }) + ) + expect(session.transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ command: expect.anything() }) + ) + expect(isPaneResumingInPlace(RECORD_PANE_KEY)).toBe(false) + + connected.resolve({ id: 'restored-pty' }) + await vi.waitFor(() => expect(session.handleReattachResult).toHaveBeenCalled()) + expect(isPaneResumingInPlace(RECORD_PANE_KEY)).toBe(false) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-request.ts new file mode 100644 index 00000000000..96e01c3bdf9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-request.ts @@ -0,0 +1,144 @@ +// Layout-owner build: a pane with no live terminal is started by main, never by the window. The +// window asks, waits for main's start to end, then attaches to the terminal main bound. + +import { useAppStore } from '@/store' +import { sendLayoutApply } from '@/runtime/layout-apply-send' +import { parsePaneKey } from '../../../../../shared/stable-pane-id' +import { recordPtyConnectDiagnostic } from './pty-connect-limits' +import { + adoptMainBinding, + showPaneStartFailure, + waitsForMainPaneStart +} from './main-pane-start-wait' +import { startDeferredSessionReattach } from './deferred-session-reattach-connect' +import { mainStartsPane } from './main-starts-pane' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +type MainPaneStartHandlers = { + /** Runs once main's start for the pane ends: the pane is bound to its terminal. */ + attach: () => void + /** Main handed a twin's start back: start it as today. */ + today?: () => void + /** Main refused the start, or showed it failed. */ + refused?: () => void +} + +/** + * Asks main to start the pane, then attaches once main's start for it ends. False when main does + * not own the pane's layout (a paired server's workspace), which keeps today's path. + */ +export function startPaneThroughMain( + session: ConnectPanePtySession, + handlers: MainPaneStartHandlers +): boolean { + const tabId = parsePaneKey(session.cacheKey)?.tabId + if (!tabId) { + return false + } + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> START THROUGH MAIN`) + const refused = (code: string): void => { + showPaneStartFailure(session, code) + handlers.refused?.() + } + return sendLayoutApply( + useAppStore.getState(), + session.deps.worktreeId, + { type: 'startPane', tabId, paneKey: session.cacheKey }, + { + // Main's reply lands after the frame that shows the pane starting, or bound when it was live. + apply: () => { + if (session.disposed || waitsForMainPaneStart(session, handlers.attach, handlers.refused)) { + return + } + adoptMainBinding(session, useAppStore.getState()) + handlers.attach() + }, + asToday: handlers.today ?? (() => refused('host_not_owned')), + refused + } + ) +} + +/** + * Layout-owner build: the window's own start of a pane (a wake, a hibernated agent's wake, a + * restart replacement, a reattach that found its terminal gone) asks main instead, then attaches + * to the terminal main bound. `today` starts it when main does not start this pane. + */ +export function startPaneThroughMainThenAttach( + session: ConnectPanePtySession, + today: () => Promise +): Promise { + if (!mainStartsPane(session)) { + return today() + } + if (session.mainStartAttach) { + // Why main's own code: the attach to main's start found no terminal; asking again would loop on a pane + // whose terminal ends at once. + showPaneStartFailure(session, 'terminal_not_started') + return Promise.resolve(null) + } + return new Promise((resolve) => { + const settleNull = (): void => resolve(null) + session.waitTeardowns.push(settleNull) + const sent = startPaneThroughMain(session, { + attach: () => { + const ptyId = session.deps.restoredPtyIdByLeafId?.[session.pane.leafId] ?? null + if (ptyId) { + // Main started a fresh terminal: follow its output, as a window start does. + session.resetFreshSpawnFollowOutput() + startDeferredSessionReattach(session, ptyId, { afterMainStart: true }) + } + resolve(ptyId) + }, + today: () => { + session.mainPaneStartHandedBack = true + resolve(today()) + }, + refused: settleNull + }) + if (!sent) { + resolve(today()) + } + }) +} + +/** + * Layout-owner build, at connect: whether main's start of the pane (under way, or one this view + * asks for) stands in for the window's own. A sleeping pane is started on its first view; a held + * pane attaches as today, and main's refusal holds it. `connect` attaches after. + */ +export function defersToMainPaneStart( + session: ConnectPanePtySession, + connect: () => void +): boolean { + if (waitsForMainPaneStart(session, connect)) { + return true + } + if (!mainStartsPane(session)) { + return false + } + if (useAppStore.getState().terminalPaneStartsByPaneKey[session.cacheKey]?.status !== 'sleeping') { + return false + } + const startOnView = (): void => { + session.mainPaneStartOnReveal = null + const today = (): void => { + session.mainPaneStartHandedBack = true + connect() + } + // Why re-armed on a refusal: a failed call is retried by the pane's next view. + const refused = (): void => { + session.mainPaneStartOnReveal = startOnView + } + if (!startPaneThroughMain(session, { attach: connect, today, refused })) { + today() + } + } + if (session.deps.isVisibleRef.current) { + startOnView() + } else { + // Why: a hidden pane waits for its first view, as a hidden sleeping agent does (#29459). + session.mainPaneStartOnReveal = startOnView + } + return true +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts new file mode 100644 index 00000000000..2fb40d1a32f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +type Starts = Record + +const store = vi.hoisted(() => { + let state: { terminalPaneStartsByPaneKey: Starts; terminalLayoutsByTabId: object } = { + terminalPaneStartsByPaneKey: {}, + terminalLayoutsByTabId: {} + } + const listeners = new Set<(next: typeof state) => void>() + return { + getState: () => state, + subscribe: (listener: (next: typeof state) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + setStarts: (starts: Starts) => { + state = { ...state, terminalPaneStartsByPaneKey: starts } + for (const listener of listeners) { + listener(state) + } + } + } +}) + +vi.mock('@/store', () => ({ useAppStore: store })) +vi.mock('../../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const PANE_KEY = 'tab-1:leaf-1' + +function session(): ConnectPanePtySession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait reads only these session members. + return { + cacheKey: PANE_KEY, + disposed: false, + waitTeardowns: [], + pane: { id: 1, leafId: 'leaf-1' }, + deps: { tabId: 'tab-1', onPtyErrorRef: { current: vi.fn() } } + } as unknown as ConnectPanePtySession +} + +describe('waitsForMainPaneStart', () => { + beforeEach(() => { + store.setStarts({}) + }) + + it('runs every waiter once main’s start ends', async () => { + const { waitsForMainPaneStart } = await import('./main-pane-start-wait') + const pane = session() + store.setStarts({ [PANE_KEY]: { status: 'starting' } }) + const first = vi.fn() + const second = vi.fn() + + expect(waitsForMainPaneStart(pane, first)).toBe(true) + expect(waitsForMainPaneStart(pane, second)).toBe(true) + store.setStarts({}) + + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + }) + + it('tells each waiter when main shows the start failed', async () => { + const { waitsForMainPaneStart } = await import('./main-pane-start-wait') + const pane = session() + store.setStarts({ [PANE_KEY]: { status: 'starting' } }) + const failedFirst = vi.fn() + const failedLater = vi.fn() + + waitsForMainPaneStart(pane, vi.fn(), failedFirst) + store.setStarts({ [PANE_KEY]: { status: 'failed', reason: 'spawn exploded' } }) + waitsForMainPaneStart(pane, vi.fn(), failedLater) + + expect(failedFirst).toHaveBeenCalledOnce() + expect(failedLater).toHaveBeenCalledOnce() + expect(pane.deps.onPtyErrorRef?.current).toHaveBeenCalledWith( + 1, + 'The terminal could not start: spawn exploded' + ) + }) + // A start main could not bind shows its reason through this wait. + it('shows terminal_not_started from main through the wait', async () => { + const { waitsForMainPaneStart } = await import('./main-pane-start-wait') + const pane = session() + store.setStarts({ [PANE_KEY]: { status: 'starting' } }) + const failed = vi.fn() + + waitsForMainPaneStart(pane, vi.fn(), failed) + store.setStarts({ [PANE_KEY]: { status: 'failed', reason: 'terminal_not_started' } }) + + expect(failed).toHaveBeenCalledOnce() + expect(pane.deps.onPtyErrorRef?.current).toHaveBeenCalledWith( + 1, + 'The terminal could not start: terminal_not_started' + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts index 02220d20807..6a92796e922 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts @@ -8,16 +8,22 @@ import type { PaneStartStatus } from '../../../../../shared/workspace-layout/wor import type { ConnectPanePtySession } from './connect-pane-pty-session' import { resolveTerminalTabId } from './terminal-tab-id' -const waiting = new WeakSet() +type PaneStartWait = { connects: Set<() => void>; failed: Set<() => void> } -/** The starts this gate covers; a sleeping, resuming or held pane keeps its restore path (C1c). */ +const waiting = new WeakMap() + +/** The starts this gate waits on; a sleeping or held pane starts on its first view (C1c). */ const gated = (status: PaneStartStatus | undefined): boolean => - status === 'starting' || status === 'failed' + status === 'starting' || status === 'resuming' || status === 'failed' -/** Whether the pane waits on main's start; `connect` runs once main's start for it ends. */ +/** + * Whether the pane waits on main's start; every waiter's `connect` runs once main's start for it + * ends, and `onFailed` runs when main shows the start failed. + */ export function waitsForMainPaneStart( session: ConnectPanePtySession, - connect: () => void + connect: () => void, + onFailed?: () => void ): boolean { if (!ORCA_LAYOUT_OWNER_IS_MAIN) { return false @@ -26,18 +32,30 @@ export function waitsForMainPaneStart( if (!start || !gated(start.status)) { return false } - if (waiting.has(session)) { + const joined = waiting.get(session) + if (joined) { + joined.connects.add(connect) + if (onFailed) { + joined.failed.add(onFailed) + if (start.status === 'failed') { + onFailed() + } + } return true } - waiting.add(session) + const wait: PaneStartWait = { connects: new Set([connect]), failed: new Set() } + if (onFailed) { + wait.failed.add(onFailed) + } + waiting.set(session, wait) recordPtyConnectDiagnostic(`pane=${session.pane.id} -> WAIT FOR MAIN START (${start.status})`) let shown = start const showFailure = (): void => { if (shown.status === 'failed' && !session.disposed) { - session.deps.onPtyErrorRef?.current?.( - session.pane.id, - `The terminal could not start: ${shown.reason ?? 'unknown error'}` - ) + showPaneStartFailure(session, shown.reason) + for (const failed of wait.failed) { + failed() + } } } showFailure() @@ -61,7 +79,9 @@ export function waitsForMainPaneStart( session.waitTeardowns.splice(index, 1) } if (!session.disposed) { - connect() + for (const waiter of wait.connects) { + waiter() + } } }) session.waitTeardowns.push(unsubscribe) @@ -69,7 +89,7 @@ export function waitsForMainPaneStart( } /** The terminal main bound the pane to, as the pane's restore id: the connect then attaches it. */ -function adoptMainBinding( +export function adoptMainBinding( session: ConnectPanePtySession, state: ReturnType ): void { @@ -81,3 +101,16 @@ function adoptMainBinding( session.deps.restoredPtyIdByLeafId = { ...session.deps.restoredPtyIdByLeafId, [leafId]: ptyId } } } + +/** The one place a pane shows why main's start of it failed (copy the owner approved). */ +export function showPaneStartFailure( + session: ConnectPanePtySession, + reason: string | undefined +): void { + if (!session.disposed) { + session.deps.onPtyErrorRef?.current?.( + session.pane.id, + `The terminal could not start: ${reason ?? 'unknown error'}` + ) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-starts-pane.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-starts-pane.ts new file mode 100644 index 00000000000..74675fb45d8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-starts-pane.ts @@ -0,0 +1,27 @@ +import { useAppStore } from '@/store' +import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Whether main starts this pane's terminal: a layout-owner build, and main owns the workspace. */ +export function mainStartsPane(session: { + deps: ConnectPanePtySession['deps'] + runtimeEnvironmentId?: string | null + mainPaneStartHandedBack?: boolean +}): boolean { + return ( + ORCA_LAYOUT_OWNER_IS_MAIN && + !session.runtimeEnvironmentId && + // Main handed a twin's start back: the window starts it as today from then on. + !session.mainPaneStartHandedBack && + !runtimeEnvironmentIdUnlessTwin(useAppStore.getState(), session.deps.worktreeId) + ) +} + +/** A hidden pane's start through main waits for its first view, as a hidden agent resume does (#29459). */ +export function startMainPaneOnFirstView(session: ConnectPanePtySession, start: () => void): void { + session.mainPaneStartOnReveal = () => { + session.mainPaneStartOnReveal = null + start() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.test.ts new file mode 100644 index 00000000000..5e076ce0489 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.test.ts @@ -0,0 +1,47 @@ +// The view's painted-restore ack names its pane in the layout-owner build, so main can release +// the terminal service's copy only for the pane's current terminal. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ackColdRestore = vi.fn() +const session = { + deps: { worktreeId: 'repo::/wt', tabId: 'tab-1' }, + pane: { leafId: 'leaf-1' } +} + +async function ackWithSwitch(on: boolean, ptyId: string): Promise { + vi.resetModules() + vi.doMock('../../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: on })) + const { ackPaintedColdRestore } = await import('./painted-cold-restore-ack') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ack reads only the session members this fixture supplies. + ackPaintedColdRestore(session as never, ptyId) +} + +describe('ackPaintedColdRestore', () => { + beforeEach(() => { + ackColdRestore.mockClear() + vi.stubGlobal('window', { api: { pty: { ackColdRestore } } }) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('acks by terminal only with the switch off', async () => { + await ackWithSwitch(false, 'pty-1') + expect(ackColdRestore).toHaveBeenCalledWith('pty-1') + }) + + it('names the painted pane in the layout-owner build', async () => { + await ackWithSwitch(true, 'pty-1') + expect(ackColdRestore).toHaveBeenCalledWith('pty-1', { + worktreeId: 'repo::/wt', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + }) + + it('never acks a paired server’s terminal, whose host owns its restore', async () => { + const { toRemoteRuntimePtyId } = await import('../../../../../shared/remote-runtime-pty-id') + await ackWithSwitch(true, toRemoteRuntimePtyId('handle-1', 'env-1')) + expect(ackColdRestore).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.ts b/src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.ts new file mode 100644 index 00000000000..b915d4e3762 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/painted-cold-restore-ack.ts @@ -0,0 +1,20 @@ +import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import type { ReattachPayloadSession } from './reattach-payload-session' + +/** The view painted `ptyId`'s cold restore; the terminal service may drop its copy. */ +export function ackPaintedColdRestore(session: ReattachPayloadSession, ptyId: string): void { + if (isRemoteRuntimePtyId(ptyId)) { + return + } + // Switch point: main releases the copy only for the pane's current terminal. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + window.api.pty.ackColdRestore(ptyId) + return + } + window.api.pty.ackColdRestore(ptyId, { + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + leafId: session.pane.leafId + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index 9a538543183..cbf1ede5197 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -5,6 +5,7 @@ import { restoreTerminalTestGlobals } from '../pty-connection-test-environment' import { createReattachPayloadHandlers } from './apply-reattach-payload' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' @@ -13,6 +14,7 @@ function createSession(overrides: Record = {}): ReattachPayload // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these session members. return { pane: createPane(1), + deps: { worktreeId: 'wt-1', tabId: 'tab-1' }, rememberReattachPayloadAgentSignal: vi.fn(), writeReplayData: vi.fn(), writeInputModeGround: vi.fn(), @@ -103,7 +105,12 @@ describe('reattach payload context', () => { await createReattachPayloadHandlers(session, context).applyReattachPayload() - expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main started the pane and resumed its agent; the window's paint applies no resume. + expect(applyColdRestoreAgentResumeStartup).not.toHaveBeenCalledWith(startup) + } else { + expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup) + } expect(buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() expect(schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() expect(context.reattachPayloadApplied).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index b4c963d29f4..7f30f25f99f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -9,6 +9,7 @@ export type ReattachPayloadSession = Pick< | 'clearSleepingRecordAfterColdRestoreSpawn' | 'consumeRestoredViewportBlankingMarker' | 'createReattachGridPush' + | 'deps' | 'isPaneOnAlternateScreen' | 'kittyKeyboardModes' | 'markHiddenOutputRestoreNeeded' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts index 73bb4ebba3f..49cb8afa1bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts @@ -5,6 +5,7 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' import type { HiddenOutputSnapshotResult } from './hidden-output-snapshot-serialize' import type { ReattachPayloadContext } from './reattach-payload-context' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' /** * A park-reveal of a remote-runtime pty arrives with `replay: ''`, so the host @@ -115,6 +116,8 @@ function buildParkRevealSession(overrides: Record = {}): Bag { clearExitedPanePtyLayoutBinding: vi.fn(), syncPanePtyLayoutBinding: vi.fn(), startFreshColdRestoreAgentResume: vi.fn(), + // Layout-owner build: the empty-reattach evidence reads the pane's record through this. + buildColdRestoreAgentResumeStartup: vi.fn(() => null), setPanePtyFitBinding: vi.fn(), reportPanePtyVisibility: vi.fn(), registerSideEffectFactConsumerForPty: vi.fn(), @@ -374,18 +377,51 @@ describe('handleReattachResult empty reattach under a hibernation note', () => { expect(bag.session.setPanePtyFitBinding).toHaveBeenCalledWith(REMOTE_PTY_ID) }) + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps the shell main kept after its start, without asking main again', + async () => { + const bag = buildParkRevealSession({ + mountFollowsTerminalPark: false, + buildColdRestoreAgentResumeStartup: vi.fn(() => startup), + mainStartAttach: {} + }) + + await expect( + bag.session.handleReattachResult({ id: 'local-pty-1', isReattach: true }, null, null) + ).resolves.toBe(true) + + expect(bag.transport.disconnect).not.toHaveBeenCalled() + expect(bag.session.startFreshColdRestoreAgentResume).not.toHaveBeenCalled() + expect(bag.session.setPanePtyFitBinding).toHaveBeenCalledWith('local-pty-1') + } + ) + it('retires an empty local fresh-spawn adoption and resumes the provider session', async () => { - const bag = buildParkRevealSession({ mountFollowsTerminalPark: false }) + const bag = buildParkRevealSession({ + mountFollowsTerminalPark: false, + // Layout-owner build: main's attach carries no resume, so the evidence reads the record. + buildColdRestoreAgentResumeStartup: vi.fn(() => startup) + }) await expect( - bag.session.handleReattachResult({ id: 'local-pty-1', isReattach: true }, null, startup) + bag.session.handleReattachResult( + { id: 'local-pty-1', isReattach: true }, + null, + ORCA_LAYOUT_OWNER_IS_MAIN ? null : startup + ) ).resolves.toBe(false) expect(bag.transport.disconnect).toHaveBeenCalledOnce() + expect(bag.session.setPanePtyFitBinding).not.toHaveBeenCalled() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main retires the shell and resumes the agent in its place; the window unbinds nothing. + expect(bag.session.syncPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(bag.session.startFreshColdRestoreAgentResume).toHaveBeenCalledExactlyOnceWith(null) + return + } expect(bag.session.syncPanePtyLayoutBinding).toHaveBeenCalledWith(null) expect(bag.session.startFreshColdRestoreAgentResume).toHaveBeenCalledExactlyOnceWith(startup, { forceBlankRestoredViewport: true }) - expect(bag.session.setPanePtyFitBinding).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 8dc18c453af..7bb483891cb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -10,6 +10,7 @@ import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-s import { shouldIgnoreStalePanePtyLayoutBinding } from './pane-pty-layout-binding' import { hasEmptyReattachRetireEvidence } from './empty-reattach-retire-evidence' +import { mainStartsPane } from './main-starts-pane' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -31,6 +32,9 @@ type ReattachResultSession = ReattachPayloadSession & Pick< ConnectPanePtySession, | 'agentCompletionCoordinator' + | 'buildColdRestoreAgentResumeStartup' + | 'mainStartAttach' + | 'runtimeEnvironmentId' | 'activePanePtyBinding' | 'activePanePtyBindingBoundAt' | 'authoritativeReattachGeneration' @@ -167,12 +171,24 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore ) // Why: reattach drops startup commands; only real hibernation is authority to retire an empty adopted shell and resume its provider session. + const mainStarts = mainStartsPane(session) if ( !hasStructuralReplay && connectResult?.isReattach && - hasEmptyReattachRetireEvidence(ptyId, coldRestoreStartup) + // Why: main already judged this terminal after its start; asking again would loop. + !(mainStarts && session.mainStartAttach) && + hasEmptyReattachRetireEvidence( + ptyId, + // Main's attach carries no resume; the evidence reads the pane's record all the same. + mainStarts ? session.buildColdRestoreAgentResumeStartup() : coldRestoreStartup + ) ) { session.transport.disconnect() + if (mainStarts) { + // Main retires the empty shell and resumes the agent in its place (startPane). + session.startFreshColdRestoreAgentResume(null) + return false + } if (staleSessionId) { session.clearExitedPanePtyLayoutBinding(staleSessionId) session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index 6c27925bfc9..22b86db8974 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -8,7 +8,7 @@ import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detect import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent' import { recordPtyConnectDiagnostic } from './pty-connect-limits' -import { waitsForMainPaneStart } from './main-pane-start-wait' +import { defersToMainPaneStart } from './main-pane-start-request' import { findTerminalTabForPane } from './terminal-tab-id' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -134,7 +134,7 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void session.connectFrame = requestAnimationFrame(session.runDeferredConnect) return } - if (waitsForMainPaneStart(session, session.runDeferredConnect)) { + if (defersToMainPaneStart(session, session.runDeferredConnect)) { session.cancelScheduledConnectFrame() if (session.connectFallbackTimer !== null) { clearTimeout(session.connectFallbackTimer) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts index c9f2f0c837a..85a80347406 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts @@ -78,6 +78,7 @@ export function installSessionReconcileDispose(session: ConnectPanePtySession): session.consumeHibernatedAgentWake() consumeRestartReplacement(session) session.resumeDeferredSpawnOnReveal() + session.mainPaneStartOnReveal?.() session.requestKnownWindowsShiftEnterReconfirmation() session.sampleVisiblePaneForegroundAgent() }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 094dd825afc..c9a0e3672a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -1,9 +1,8 @@ import { useAppStore } from '@/store' import type { PtyConnectResult } from '../pty-transport-types' import { createBrowserUuid } from '@/lib/browser-uuid' -import { parseLegacyNumericPaneKey } from '../../../../../shared/stable-pane-id' +import { findLegacyNumericSleepingRecord } from '../../../../../shared/sleeping-record-legacy-pane-key' import { neutralTerminalTitle } from '../../../../../shared/terminal-pane-title-reset' -import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership' import { agentProviderSessionsEqual, type SleepingAgentSessionRecord @@ -28,40 +27,12 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi if (stableRecord) { return { paneKey: session.cacheKey, record: stableRecord } } - const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( - ([paneKey, record]) => { - const legacy = parseLegacyNumericPaneKey(paneKey) - return ( - legacy?.tabId === session.deps.tabId && - record.worktreeId === session.deps.worktreeId && - (!record.tabId || record.tabId === session.deps.tabId) - ) - } + const legacy = findLegacyNumericSleepingRecord( + state.sleepingAgentSessionsByPaneKey, + { tabId: session.deps.tabId, numericPaneId: String(session.pane.id) }, + (record) => record.worktreeId === session.deps.worktreeId ) - const exactLegacyMatch = legacyMatches.find(([paneKey]) => { - const legacy = parseLegacyNumericPaneKey(paneKey) - return legacy?.numericPaneId === String(session.pane.id) - }) - const providerSessionKeys = new Set( - legacyMatches.map(([, record]) => getProviderSessionClaimKey(record)) - ) - const oldestLegacyMatch = legacyMatches - .slice() - .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] - // Why: duplicate legacy aliases can point at one provider session; consume - // the oldest capture as canonical and clear its aliases after resume. - const selectedLegacyMatch = - exactLegacyMatch ?? - (providerSessionKeys.size === 1 - ? legacyMatches.length === 1 - ? legacyMatches[0] - : oldestLegacyMatch - : null) - if (!selectedLegacyMatch) { - return null - } - const [paneKey, record] = selectedLegacyMatch - return { paneKey, record } + return legacy ? { paneKey: legacy.paneKey, record: legacy.record } : null } session.clearSleepingRecordProviderDuplicates = ( state: ReturnType, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts index 265a86785d0..f26b2578270 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts @@ -24,6 +24,7 @@ import { } from '@/runtime/resume-host-automatic-restores' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { releaseHiddenAgentResumeHold } from './hidden-agent-resume-deferral' +import { startPaneThroughMainThenAttach } from './main-pane-start-request' import type { ColdRestoreAgentResumeStartup, FreshSpawnOptions, @@ -208,7 +209,9 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void clearPaneSpawnDeferral(session.cacheKey) } } - return session.startFreshSpawn(deferred.startupOverride, deferred.options).then( + return startPaneThroughMainThenAttach(session, () => + session.startFreshSpawn(deferred.startupOverride, deferred.options) + ).then( (ptyId: string | null) => { settle() return ptyId diff --git a/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts b/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts index c3c2e92c860..41a547b8354 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts @@ -1 +1,22 @@ -export { STARTUP_CWD_FALLBACK_NOTICE } from '../../../../../shared/startup-cwd-fallback' +import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { STARTUP_CWD_FALLBACK_NOTICE } from '../../../../../shared/startup-cwd-fallback' +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export { STARTUP_CWD_FALLBACK_NOTICE } + +/** Tells the pane its saved folder was gone, so its terminal started at the workspace root. */ +export function writeStartupCwdFallbackNotice( + session: ConnectPanePtySession, + result: unknown +): void { + if (!result || typeof result !== 'object' || !('startupCwdFallback' in result)) { + return + } + if (!result.startupCwdFallback) { + return + } + writeTerminalOutput(session.pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, { + foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 097dee4ae20..22099a5c2ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -180,6 +180,8 @@ export type PtyTransport = { * first byte and the gate + model responder own spawn-time queries. * Ignored by remote-runtime transports (not gate-markable). */ initiallyHidden?: boolean + /** Layout-owner build: attach to `sessionId` or start nothing; main starts the pane. */ + attachOnlyView?: true /** Sent only when this connect resumes an agent, and only to hosts that accept it. */ resumeSpawnIntent?: RestoreSpawnIntent command?: string diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.test.ts new file mode 100644 index 00000000000..ef5d0e35656 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.test.ts @@ -0,0 +1,81 @@ +// A restored pane's history: the window's saved copy only with the switch off. Layout-owner +// builds paint history only from the terminal's host (L236). +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const restoreScrollbackBuffers = vi.fn() +const setTabLayout = vi.fn() +const setTabLocalOnlyScrollback = vi.fn() +const TAB = 'tab-1' +const LEAF = 'leaf-1' + +vi.mock('./layout-serialization', () => ({ + replayTerminalLayout: () => new Map([[LEAF, 1]]), + restoreScrollbackBuffers: (...args: unknown[]) => restoreScrollbackBuffers(...args) +})) +vi.mock('./expand-collapse', () => ({ applyExpandedLayoutTo: vi.fn() })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + localOnlyScrollbackByTabId: { [TAB]: { [LEAF]: 'local-only history' } }, + repos: [{ id: 'repo', connectionId: 'ssh-1' }], + setTabLayout, + setTabLocalOnlyScrollback + }) + } +})) + +async function restoreWithSwitch(on: boolean): Promise { + vi.resetModules() + vi.doMock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: on })) + const { restoreTerminalPaneLayout } = await import('./terminal-pane-layout-restore') + const manager = { + getActivePane: () => ({ id: 1 }), + getPanes: () => [{ id: 1 }], + setActivePane: vi.fn() + } + const initialLayoutRef = { + current: { + root: { type: 'leaf' as const, leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + buffersByLeafId: { [LEAF]: 'shared history' } + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the members this fixture supplies. + const args = { + manager, + deps: { + initialLayoutRef, + tabId: TAB, + worktreeId: 'repo::/wt', + isActive: false, + managerRef: { current: manager }, + replayingPanesRef: { current: new Map() }, + setPaneTitles: vi.fn(), + paneTitlesRef: { current: {} }, + setExpandedPane: vi.fn() + }, + refs: { restoredViewportBlankingPanesRef: { current: new Set() } }, + ptyDeps: { mountFollowsTerminalPark: false }, + initialLayoutHadBuffers: true + } as unknown as Parameters[0] + restoreTerminalPaneLayout(args) +} + +describe('restoreTerminalPaneLayout history source', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('paints the window copy with the switch off', async () => { + await restoreWithSwitch(false) + expect(restoreScrollbackBuffers.mock.calls[0]?.[1]).toEqual({ [LEAF]: 'local-only history' }) + }) + + it('paints no window copy in the layout-owner build, and keeps the copy untouched', async () => { + await restoreWithSwitch(true) + expect(restoreScrollbackBuffers.mock.calls[0]?.[1]).toBeUndefined() + expect(setTabLayout).not.toHaveBeenCalled() + expect(setTabLocalOnlyScrollback).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts index e3634481502..82ea36ecc43 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts @@ -8,6 +8,7 @@ import type { TerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-re import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types' import type { PtyConnectionDeps } from './pty-connection-types' import { resolveParkRevealLiveTuiLeafIds } from './park-reveal-live-tui-leaves' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { mapRestoredPaneTitlesByPaneId, replayLayoutWithOneShotParkIntent @@ -30,11 +31,16 @@ export function restoreTerminalPaneLayout(args: { const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () => replayTerminalLayout(manager, initialLayoutRef.current, isActive) ) - const localOnlyBuffers = useAppStore.getState().localOnlyScrollbackByTabId[tabId] - const restoredBuffers = resolveLeafScrollbackBuffers({ - shared: initialLayoutRef.current, - localOnly: localOnlyBuffers - }) + // Switch point: history comes only from the terminal's host (L236), never the window's copy. + const localOnlyBuffers = ORCA_LAYOUT_OWNER_IS_MAIN + ? undefined + : useAppStore.getState().localOnlyScrollbackByTabId[tabId] + const restoredBuffers = ORCA_LAYOUT_OWNER_IS_MAIN + ? undefined + : resolveLeafScrollbackBuffers({ + shared: initialLayoutRef.current, + localOnly: localOnlyBuffers + }) restoreScrollbackBuffers( manager, restoredBuffers, diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index 60a28510605..b4e9b34444f 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -8,18 +8,14 @@ import type { } from '../../../shared/terminal-tab-types' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { getProviderSessionClaimKey } from '../../../shared/sleeping-record-legacy-pane-key' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { isPaneResumingInPlace, isPaneSpawnDeferred } from './pane-spawn-deferral-registry' -type AppStoreState = ReturnType +export { getProviderSessionClaimKey } -export function getProviderSessionClaimKey(record: SleepingAgentSessionRecord): string { - const base = `${record.worktreeId}\0${record.agent}\0${record.providerSession.key}\0${record.providerSession.id}` - return record.agent === 'pi' || record.agent === 'prime-agent' - ? `${base}\0${record.providerSession.transcriptPath ?? ''}` - : base -} +type AppStoreState = ReturnType // Why live+done counts (#16308): workspace activation must not resume a finished turn's idle anchor. // Quit asks to keep work resumable and a live stopped or failed turn is unfinished. diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index b40183adeef..bbf5ae79274 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -138,6 +138,8 @@ export type WindowLayoutCommand = /** Records how the panes' agents resume without stopping them (a live or quit capture). */ recordOnly?: true } + /** Main starts the pane if its terminal is not live; the window then attaches. */ + | { type: 'startPane'; tabId: string; paneKey: string } | { type: 'wake'; tabId: string; paneKeys: [string] } /** Drops the panes' resume records without starting them. */ | { type: 'wake'; tabId: string; paneKeys: string[]; recordOnly: true } @@ -206,6 +208,7 @@ function drawnTabHolds(worktreeId: string, command: WindowLayoutCommand): DrawnT case 'movePaneToNewTab': case 'sleep': case 'wake': + case 'startPane': case 'setDefaultTabsApplied': return [] } diff --git a/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts b/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts index 72b8f42885a..6c3ec9e8b18 100644 --- a/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts +++ b/src/renderer/src/runtime/layout-apply-test-transport.test-fixture.ts @@ -307,9 +307,15 @@ export function runtimeRpcClientWithLayoutApply(actual: RpcClientModule): RpcCli } return actual.callRuntimeRpc(target, method, params, options) } - return { ...actual, callRuntimeRpc } + const client = { ...actual, callRuntimeRpc } + // Why: vi.resetModules keeps the mocked client, so a test's later import of this fixture is a + // fresh copy; tests read the copy the mocked client answers with from this property. + Object.defineProperty(client, ANSWERING_TRANSPORT, { value: layoutApplyTestTransport }) + return client } +export const ANSWERING_TRANSPORT = 'layoutApplyTestTransport' + type LayoutClientModule = typeof LayoutClient /** The layout client module with the local stream fed by the transport: main's model's snapshot diff --git a/src/renderer/src/runtime/sleeping-records-to-layout-owner.test.ts b/src/renderer/src/runtime/sleeping-records-to-layout-owner.test.ts index d948c17cc31..d82880a2e39 100644 --- a/src/renderer/src/runtime/sleeping-records-to-layout-owner.test.ts +++ b/src/renderer/src/runtime/sleeping-records-to-layout-owner.test.ts @@ -136,7 +136,7 @@ describe('main’s copy of the window’s resume records', () => { expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) - it('sends a resumed pane’s real wake before the record drop that follows it', () => { + it('sends a resumed pane’s wake, record-only so main starts nothing, before the record drop', () => { setRecords({ [PANE]: record(PANE, LOCAL) }) mocks.callRuntimeRpc.mockClear() @@ -144,7 +144,7 @@ describe('main’s copy of the window’s resume records', () => { held.store!.getState().clearSleepingAgentSession(PANE) expect(sent()).toEqual([ - { type: 'wake', workspace: LOCAL, tabId: TAB, paneKeys: [PANE] }, + { type: 'wake', workspace: LOCAL, tabId: TAB, paneKeys: [PANE], recordOnly: true }, { type: 'wake', workspace: LOCAL, tabId: TAB, paneKeys: [PANE], recordOnly: true } ]) }) diff --git a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts index cc04c543d10..56d5a67b10b 100644 --- a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts +++ b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.test.ts @@ -77,13 +77,14 @@ describe('sleep and wake through the layout owner', () => { expect(mocks.send).not.toHaveBeenCalled() }) - it('wakes the one pane the window resumed', () => { + it('records the wake of the one pane the window resumed, and has main start nothing', () => { recordPaneWakeThroughLayoutOwner(STATE, WT, A2) expect(mocks.send).toHaveBeenCalledWith(STATE, WT, { type: 'wake', tabId: 'tab-a', - paneKeys: [A2] + paneKeys: [A2], + recordOnly: true }) }) diff --git a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts index 0f55d173388..6ecb5d608e0 100644 --- a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts @@ -93,7 +93,10 @@ export function recordSleepingRecordChangesThroughLayoutOwner( } } -/** A layout-owner build's record of the one pane this window showed and resumed. */ +/** + * A layout-owner build's record of the one pane this window showed and resumed itself: record-only, + * since a plain wake has main start the pane too. + */ export function recordPaneWakeThroughLayoutOwner( state: WorktreeRuntimeOwnerState, worktreeId: string, @@ -101,6 +104,11 @@ export function recordPaneWakeThroughLayoutOwner( ): void { const tabId = parsePaneKey(paneKey)?.tabId if (tabId) { - recordLayoutApply(state, worktreeId, { type: 'wake', tabId, paneKeys: [paneKey] }) + recordLayoutApply(state, worktreeId, { + type: 'wake', + tabId, + paneKeys: [paneKey], + recordOnly: true + }) } } diff --git a/src/shared/hibernation-done-record.ts b/src/shared/hibernation-done-record.ts new file mode 100644 index 00000000000..e0bd66c8edb --- /dev/null +++ b/src/shared/hibernation-done-record.ts @@ -0,0 +1,6 @@ +import { isPaneSleepRecord, type SleepingAgentSessionRecord } from './agent-session-resume' + +// Why not activationTreatsNoteAsFinished: a live+done note is a finished turn's idle anchor for a running pane, not sleep. +export function isHibernationDoneRecord(record: SleepingAgentSessionRecord): boolean { + return isPaneSleepRecord(record) && record.state === 'done' +} diff --git a/src/shared/painted-restore-ack.ts b/src/shared/painted-restore-ack.ts new file mode 100644 index 00000000000..727973e53e7 --- /dev/null +++ b/src/shared/painted-restore-ack.ts @@ -0,0 +1,5 @@ +/** The pane a view painted a cold restore into; main releases the restore only for its current terminal. */ +export type PaintedRestorePane = { worktreeId: string; tabId: string; leafId: string } + +/** `pty:ackColdRestore`'s payload. `pane` is sent only by layout-owner builds. */ +export type PaintedRestoreAck = { id: string; pane?: PaintedRestorePane } diff --git a/src/shared/pane-attach-only-view.ts b/src/shared/pane-attach-only-view.ts new file mode 100644 index 00000000000..fc4b35a9367 --- /dev/null +++ b/src/shared/pane-attach-only-view.ts @@ -0,0 +1,2 @@ +/** A window's attach-only view of a pane found no live terminal: main starts it, never the window. */ +export const PANE_NOT_LIVE_ERROR = 'terminal_pane_not_live' diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 67c84e14642..5249471fc93 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { isTerminalLeafId } from '../stable-pane-id' -import { SleepCommand, WakeCommand } from './workspace-layout-sleep-wake-params' +import { SleepCommand, StartPaneCommand, WakeCommand } from './workspace-layout-sleep-wake-params' import { RenameTabsCommand } from './workspace-layout-rename-tabs-params' import { EditorFileRecord, EditorViewRecord } from './workspace-layout-editor-file-params' import { SetGroupRatiosCommand, SetPaneRatiosCommand } from './workspace-layout-ratio-params' @@ -334,6 +334,7 @@ export const LayoutApplyParams = z.object({ RenameEditorFilesCommand, SleepCommand, WakeCommand, + StartPaneCommand, SetDefaultTabsAppliedCommand, ApplyDefaultTabsCommand ]) diff --git a/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts b/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts index cdd085554e4..7765cf87f0b 100644 --- a/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts +++ b/src/shared/rpc-contract/workspace-layout-sleep-wake-params.ts @@ -1,4 +1,4 @@ -// `layout.apply`'s sleep and wake commands (see LayoutApplyParams). +// `layout.apply`'s sleep, wake and pane start commands (see LayoutApplyParams). import { z } from 'zod' import { parsePaneKey } from '../stable-pane-id' import { sleepingAgentSessionRecordSchema } from '../workspace-session-sleeping-agents' @@ -44,3 +44,8 @@ export const WakeCommand = z }) .refine((command) => command.recordOnly === true || command.paneKeys.length === 1) .refine(paneKeysNameTab) + +/** The window's start of one pane it shows that has no live terminal; main starts it, never the window. */ +export const StartPaneCommand = z + .object({ type: z.literal('startPane'), ...OnTab, paneKey: z.string().min(1) }) + .refine((command) => parsePaneKey(command.paneKey)?.tabId === command.tabId) diff --git a/src/shared/sleeping-record-legacy-pane-key.test.ts b/src/shared/sleeping-record-legacy-pane-key.test.ts new file mode 100644 index 00000000000..4b49e937d13 --- /dev/null +++ b/src/shared/sleeping-record-legacy-pane-key.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from './agent-session-resume' +import { findLegacyNumericSleepingRecord } from './sleeping-record-legacy-pane-key' +import { worktreeIdsEqual } from './worktree/id' + +// An SSH workspace: its records name the remote path, as the desktop window reads them. +const SSH_WORKSPACE = 'ssh-repo::/home/dev/project' + +function record(paneKey: string, id: string, capturedAt = 1): SleepingAgentSessionRecord { + return { + paneKey, + tabId: 'tab-1', + worktreeId: SSH_WORKSPACE, + agent: 'claude', + providerSession: { key: 'session_id', id }, + prompt: 'finish', + state: 'done', + capturedAt, + updatedAt: 1, + origin: 'worktree-sleep' + } +} + +const inWorkspace = (candidate: SleepingAgentSessionRecord): boolean => + worktreeIdsEqual(candidate.worktreeId, SSH_WORKSPACE) + +describe('findLegacyNumericSleepingRecord', () => { + it('takes the record with the pane’s number', () => { + const records = { 'tab-1:2': record('tab-1:2', 'a'), 'tab-1:3': record('tab-1:3', 'b') } + expect( + findLegacyNumericSleepingRecord(records, { tabId: 'tab-1', numericPaneId: '3' }, inWorkspace) + ).toMatchObject({ paneKey: 'tab-1:3', aliases: ['tab-1:2', 'tab-1:3'] }) + }) + + it('with no number (main), takes the oldest alias of the tab’s one conversation', () => { + const records = { + 'tab-1:2': record('tab-1:2', 'a', 5), + 'tab-1:3': record('tab-1:3', 'a', 2) + } + expect(findLegacyNumericSleepingRecord(records, { tabId: 'tab-1' }, inWorkspace)).toMatchObject( + { paneKey: 'tab-1:3' } + ) + }) + + it('takes nothing when the tab’s numbered records name two conversations, or another workspace', () => { + const two = { 'tab-1:2': record('tab-1:2', 'a'), 'tab-1:3': record('tab-1:3', 'b') } + expect(findLegacyNumericSleepingRecord(two, { tabId: 'tab-1' }, inWorkspace)).toBeNull() + const elsewhere = { 'tab-1:2': { ...record('tab-1:2', 'a'), worktreeId: 'repo::/other' } } + expect(findLegacyNumericSleepingRecord(elsewhere, { tabId: 'tab-1' }, inWorkspace)).toBeNull() + }) +}) diff --git a/src/shared/sleeping-record-legacy-pane-key.ts b/src/shared/sleeping-record-legacy-pane-key.ts new file mode 100644 index 00000000000..da10df4dc2f --- /dev/null +++ b/src/shared/sleeping-record-legacy-pane-key.ts @@ -0,0 +1,59 @@ +// A sleeping record a pre-stable-id build keyed by the pane's number (`tab:2`) instead of its leaf. +// Shipped builds still write them for agents in panes spawned before the stable-id migration. + +import type { SleepingAgentSessionRecord } from './agent-session-resume' +import { parseLegacyNumericPaneKey } from './stable-pane-id' + +/** One provider conversation in a workspace; aliases of one conversation share it. */ +export function getProviderSessionClaimKey(record: SleepingAgentSessionRecord): string { + const base = `${record.worktreeId}\0${record.agent}\0${record.providerSession.key}\0${record.providerSession.id}` + return record.agent === 'pi' || record.agent === 'prime-agent' + ? `${base}\0${record.providerSession.transcriptPath ?? ''}` + : base +} + +/** + * The tab's numbered record for the pane: the one with its number, else the oldest capture when + * every numbered record in the tab names one conversation. `aliases`: every numbered key of the tab + * in this workspace. A pane whose number is unknown (main) matches only by the one-conversation rule. + */ +export function findLegacyNumericSleepingRecord( + records: Readonly>, + pane: { tabId: string; numericPaneId?: string }, + inWorkspace: (record: SleepingAgentSessionRecord) => boolean +): { paneKey: string; record: SleepingAgentSessionRecord; aliases: string[] } | null { + const legacyMatches = Object.entries(records).filter(([paneKey, record]) => { + const legacy = parseLegacyNumericPaneKey(paneKey) + return ( + legacy?.tabId === pane.tabId && + inWorkspace(record) && + (!record.tabId || record.tabId === pane.tabId) + ) + }) + const exactLegacyMatch = + pane.numericPaneId === undefined + ? undefined + : legacyMatches.find( + ([paneKey]) => parseLegacyNumericPaneKey(paneKey)?.numericPaneId === pane.numericPaneId + ) + const providerSessionKeys = new Set( + legacyMatches.map(([, record]) => getProviderSessionClaimKey(record)) + ) + const oldestLegacyMatch = legacyMatches + .slice() + .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] + // Why: duplicate legacy aliases can point at one provider session; consume + // the oldest capture as canonical and clear its aliases after resume. + const selected = + exactLegacyMatch ?? + (providerSessionKeys.size === 1 + ? legacyMatches.length === 1 + ? legacyMatches[0] + : oldestLegacyMatch + : null) + if (!selected) { + return null + } + const [paneKey, record] = selected + return { paneKey, record, aliases: legacyMatches.map(([key]) => key) } +} diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index f5545099ef7..ada7bc9f969 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -236,7 +236,7 @@ export type LayoutCommand = /** Records how each agent resumes and stops nothing: the window's live and quit captures. */ recordOnly?: true }) - /** `recordOnly`: drops the records and starts nothing. */ + /** Starts the slept panes, which keep their records. `recordOnly`: drops them, starts nothing. */ | (On & { type: 'wake'; paneKeys?: string[]; recordOnly?: true }) /** The workspace's default tabs were applied (or it had none): never apply them again. */ | (On & { type: 'setDefaultTabsApplied' }) @@ -321,6 +321,7 @@ export type LayoutRefusalCode = | 'pane_structure_changed' | 'same_pane' | 'last_pane' + /** Sent by older hosts only: a startPane of an unbound pane with a sleeping record. */ | 'pane_sleeping' | 'pane_already_bound' | 'workspace_exists' @@ -384,6 +385,13 @@ export type LayoutContext = { now: () => number /** True while a start of this pane is in flight; such a pane is not started again. */ isStarting?: (leafId: string) => boolean + /** + * Whether the pane's bound terminal is live, as the runtime knows it; a binding with a start + * status, or to a terminal that ended, names what the pane restores from. Absent: live. + */ + isBindingLive?: (paneKey: string, ptyId: string) => boolean + /** Whether the live bound terminal is an empty shell a hibernated agent left: startPane replaces it. */ + retiresBinding?: (paneKey: string, ptyId: string) => boolean } export type LayoutApplyResult = diff --git a/src/shared/workspace-layout/workspace-layout-commands.test.ts b/src/shared/workspace-layout/workspace-layout-commands.test.ts index ac9107b0db8..e53f1176312 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.test.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.test.ts @@ -235,7 +235,7 @@ describe('layout command refusals', () => { ).toBe('invalid_params') }) - it('startPane refuses a missing or sleeping pane; restartPane a missing one', () => { + it('startPane refuses a missing pane and starts a slept one from its record; restartPane refuses a missing one', () => { const { model, tabB, leafB } = setup() const paneKey = `${tabB}:${leafB}` expect(refusal(model, { type: 'startPane', workspace: WS, paneKey: 'nope:x' })).toBe( @@ -260,13 +260,24 @@ describe('layout command refusals', () => { [{ type: 'sleep', workspace: WS, records: [record] }], model ).model - expect(refusal(slept, { type: 'startPane', workspace: WS, paneKey })).toBe('pane_sleeping') - const woken = build(testContext(), [{ type: 'wake', workspace: WS }], slept) - expect(woken.results[0]!.woken).toEqual([paneKey]) - expect(refusal(woken.model, { type: 'startPane', workspace: WS, paneKey })).toBe('applied') + // The runtime's start resumes the agent the record names, so a slept pane starts from it. + expect( + applyLayoutCommand(slept, { type: 'startPane', workspace: WS, paneKey }, context) + ).toMatchObject({ ok: true, effects: { startPaneKeys: [paneKey] } }) + // A wake starts the pane and keeps its record; a record-only wake drops it. + const woken = applyLayoutCommand(slept, { type: 'wake', workspace: WS }, context) + expect(woken).toMatchObject({ + ok: true, + result: { woken: [paneKey] }, + effects: { startPaneKeys: [paneKey] } + }) + expect(woken.ok && woken.model).toEqual(slept) + const dropped = build(testContext(), [{ type: 'wake', workspace: WS, recordOnly: true }], slept) + expect(dropped.results[0]!.woken).toEqual([paneKey]) + expect(dropped.model.workspaces[WS]!.leaves?.[leafB]?.sleeping).toBeUndefined() }) - it('startPane leaves a bound pane with a live resume record alone, and refuses an unbound one', () => { + it('startPane leaves a live pane with a resume record alone, and starts one restoring from its record', () => { const { model, tabB, leafB } = setup() const paneKey = `${tabB}:${leafB}` const sleeping = { @@ -293,8 +304,42 @@ describe('layout command refusals', () => { ok: true, effects: { startPaneKeys: [] } }) - // Its terminal is gone: a plain start would lose how the agent resumes. - expect(refusal(withLeaf({}), start)).toBe('pane_sleeping') + // Its terminal is gone, or a start status says its binding is what it restores from. + expect(applyLayoutCommand(withLeaf({}), start, context)).toMatchObject({ + ok: true, + effects: { startPaneKeys: [paneKey] } + }) + expect( + applyLayoutCommand(withLeaf({ ptyId: 'pty-b' }), start, { + ...context, + isBindingLive: (key) => key !== paneKey + }) + ).toMatchObject({ ok: true, effects: { startPaneKeys: [paneKey] } }) + }) + + it('startPane replaces an empty shell a hibernated agent left, as a restart does', () => { + const { model, tabB, leafB } = setup() + const paneKey = `${tabB}:${leafB}` + const workspace = model.workspaces[WS]! + const bound: WorkspaceLayoutModel = { + ...model, + workspaces: { + ...model.workspaces, + [WS]: { ...workspace, leaves: { ...workspace.leaves, [leafB]: { ptyId: 'pty-b' } } } + } + } + const start = { type: 'startPane', workspace: WS, paneKey } as const + + const applied = applyLayoutCommand(bound, start, { + ...context, + retiresBinding: (key, ptyId) => key === paneKey && ptyId === 'pty-b' + }) + + expect(applied).toMatchObject({ + ok: true, + effects: { stopPtyIds: ['pty-b'], startPaneKeys: [paneKey] } + }) + expect(applied.ok && applied.model.workspaces[WS]!.leaves?.[leafB]?.ptyId).toBeUndefined() }) it('a record-only sleep and wake change the resume record and stop or start nothing', () => { diff --git a/src/shared/workspace-layout/workspace-layout-process-commands.ts b/src/shared/workspace-layout/workspace-layout-process-commands.ts index b9df59dfbcd..228c346ad83 100644 --- a/src/shared/workspace-layout/workspace-layout-process-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-process-commands.ts @@ -35,9 +35,11 @@ function hasResumeRecord(leaf: LayoutLeaf | undefined): boolean { } /** - * Idempotent: starts or restores a pane that has no live terminal. A bound pane, or one whose - * start is in flight, is left alone; only restartPane replaces a terminal. A pane with no - * terminal and a resume record is refused: a plain start would lose how its agent resumes. + * Idempotent: starts or restores a pane that has no live terminal. A bound pane is live unless the + * runtime knows otherwise (a start status, or an ended terminal), so its binding names what it + * restores from; a live pane, or one whose start is in flight, is left alone, and only + * restartPane replaces a terminal. The runtime's start + * resumes the agent a pane's sleeping record names, so a slept pane starts from its record. */ export function startPane( model: WorkspaceLayoutModel, @@ -50,12 +52,13 @@ export function startPane( return refuse('pane_not_found') } const leaf = workspace.leaves?.[pane.leafId] - // A live terminal comes first: a running agent's resume record does not make its pane asleep. - if (leaf?.ptyId || context.isStarting?.(pane.leafId)) { - return applied(model) + if (leaf?.ptyId && context.retiresBinding?.(command.paneKey, leaf.ptyId)) { + return restartPane(model, { ...command, type: 'restartPane' }) } - if (hasResumeRecord(leaf)) { - return refuse('pane_sleeping') + // A live terminal comes first: a running agent's resume record does not make its pane asleep. + const live = leaf?.ptyId && (context.isBindingLive?.(command.paneKey, leaf.ptyId) ?? true) + if (live || context.isStarting?.(pane.leafId)) { + return applied(model) } return applied(model, {}, { startPaneKeys: [command.paneKey] }, { [pane.leafId]: null }) } @@ -110,6 +113,10 @@ export function sleep(model: WorkspaceLayoutModel, command: CommandOf<'sleep'>): ) } +/** + * Without `recordOnly`, starts the slept panes and keeps their records: the runtime's start resumes + * from them and keeps them in the pane-owned form, so a failed start or a host crash resumes again. + */ export function wake(model: WorkspaceLayoutModel, command: CommandOf<'wake'>): Applied { let workspace = model.workspaces[command.workspace]! // A recordOnly wake drops either record (how a live one is cleared); a start wakes only slept panes. @@ -120,6 +127,10 @@ export function wake(model: WorkspaceLayoutModel, command: CommandOf<'wake'>): A (command.recordOnly ? hasResumeRecord(leaf) : leaf?.sleeping !== undefined) ) }) + const paneKeys = woken.map((pane) => pane.paneKey) + if (!command.recordOnly) { + return applied(model, { woken: paneKeys }, { startPaneKeys: paneKeys }) + } for (const { leafId } of woken) { workspace = setLeaf( workspace, @@ -127,10 +138,5 @@ export function wake(model: WorkspaceLayoutModel, command: CommandOf<'wake'>): A omitStoredFields(workspace.leaves![leafId]!, ['sleeping', 'resume']) ) } - const paneKeys = woken.map((pane) => pane.paneKey) - return applied( - withWorkspace(model, command.workspace, workspace), - { woken: paneKeys }, - { startPaneKeys: command.recordOnly ? [] : paneKeys } - ) + return applied(withWorkspace(model, command.workspace, workspace), { woken: paneKeys }) } diff --git a/src/shared/workspace-layout/workspace-layout-resume-record.test.ts b/src/shared/workspace-layout/workspace-layout-resume-record.test.ts index ccb6831f171..7269c6e57db 100644 --- a/src/shared/workspace-layout/workspace-layout-resume-record.test.ts +++ b/src/shared/workspace-layout/workspace-layout-resume-record.test.ts @@ -128,7 +128,8 @@ describe('the sleep, wake and start commands on a resume record', () => { expect(woken.ok && leafOf(woken.model, leaf(1))).toMatchObject({ resume: { origin: 'live' } }) }) - it('refuses a plain start of an unbound pane holding a resume record', () => { + // C1c: a start resumes the agent from the pane's record, so a record no longer refuses it. + it('starts an unbound pane holding a resume record, keeping the record to resume from', () => { const context = testContext() const unboundPane = `tab-unbound:${leaf(4)}` const { layout } = load(localDesktopSession()) @@ -147,10 +148,9 @@ describe('the sleep, wake and start commands on a resume record', () => { ).model expect(leafOf(held, leaf(4))!.resume).toBeDefined() const start = { type: 'startPane' as const, workspace: GIT_KEY, paneKey: unboundPane } - expect(applyLayoutCommand(held, start, context)).toMatchObject({ - ok: false, - code: 'pane_sleeping' - }) + const started = applyLayoutCommand(held, start, context) + expect(started.ok && started.effects?.startPaneKeys).toEqual([unboundPane]) + expect(started.ok && leafOf(started.model, leaf(4))!.resume).toBeDefined() // A bound pane's resume record does not stop it: its terminal runs. const bound = { type: 'startPane' as const, workspace: GIT_KEY, paneKey: shellPane } const live = build( diff --git a/tests/e2e/helpers/layout-owner-build.ts b/tests/e2e/helpers/layout-owner-build.ts new file mode 100644 index 00000000000..f9156d0610f --- /dev/null +++ b/tests/e2e/helpers/layout-owner-build.ts @@ -0,0 +1,7 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +/** Whether the app under test was built with ORCA_LAYOUT_OWNER=main. */ +export const LAYOUT_OWNER_BUILD = existsSync( + path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN') +) diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts index dc48a7cbb53..9fade447e10 100644 --- a/tests/e2e/live-background-terminal-mount-authority.spec.ts +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -29,6 +29,7 @@ import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override' import { FAKE_CODEX_LAUNCH_PROBES_SOURCE } from './helpers/fake-codex-launch-probes' +import { LAYOUT_OWNER_BUILD } from './helpers/layout-owner-build' type SpawnEvent = { args: string[]; pid: number } type TerminalIdentity = Pick< @@ -421,7 +422,8 @@ async function faultProjectionAndActivate( }, { activeTabId, - identities: terminals.map(({ tabId, leafId }) => ({ tabId, leafId })), + // Why none with the switch on: main's frames own the bindings, and no window write drops one. + identities: LAYOUT_OWNER_BUILD ? [] : terminals, worktreeId } )