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 a646d89b26.

* 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 420e605ad0.

* 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
This commit is contained in:
Jinwoo Hong
2026-10-21 22:13:57 -04:00
committed by GitHub
parent ea10994820
commit ed13ca1e6f
111 changed files with 3957 additions and 303 deletions
@@ -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<string, unknown>): Promise<{ id: string }>
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test doubles implement only the members these spawn paths read.
const testDouble = <T>(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<string, unknown>) {
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<RuntimeSpawnController>(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)
})
})
+5
View File
@@ -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
@@ -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<typeof vi.fn> {
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()
})
})
@@ -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'
@@ -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()
@@ -9,6 +9,9 @@ export const interactiveOutputCharsByPty = new Map<string, number>()
export const activeRendererPtys = new Set<string>()
export const visibleRendererPtys = new Set<string>()
export const rendererVisibilityKnownPtys = new Set<string>()
// 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<string, { views: number; skippedHide: boolean }>()
// 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
@@ -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<PaintedRestoreAck>,
boundPtyId: string | null
): Promise<void> {
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()
})
})
@@ -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<Record<keyof PaintedRestorePane, unknown>> = 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<PaintedRestoreAck> | 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)
}
}
+6 -5
View File
@@ -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<PaintedRestoreAck> | 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,
+17 -1
View File
@@ -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 =
+4 -1
View File
@@ -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<void> {
@@ -64,7 +65,9 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
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) {
+3
View File
@@ -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) {
+3
View File
@@ -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<PtySpawnResult>
/** See StablePaneSpawnContext; adoption holds the pane's reservation through its fresh launch. */
keepAbsentBinding?: (dead: ReplacedPaneOwner) => void
attachOnlyView?: true
}
export type AdoptStablePaneResult = {
+9
View File
@@ -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)
})
}
+2 -1
View File
@@ -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)
@@ -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<typeof vi.fn>): 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()
})
})
+95
View File
@@ -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<PtySpawnResult> {
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<T extends object>(
ptyId: string,
setHidden: (id: string, hidden: boolean) => void,
attach: () => Promise<T>
): Promise<T> {
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
}
+18 -5
View File
@@ -21,8 +21,10 @@ export type PaneSpawnReservationResult = {
export const paneSpawnReservationsByOwnerKey = new Map<string, PaneSpawnReservation>()
export type PendingRuntimePaneCreate = {
count: number
promise: Promise<void>
promise: Promise<PaneSpawnReservationResult | undefined>
resolve: () => void
/** The terminal the create started, for a view that waited on it (layout-owner build). */
result?: PaneSpawnReservationResult
}
export const pendingRuntimePaneCreatesByOwnerKey = new Map<string, PendingRuntimePaneCreate>()
@@ -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<void>((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<T extends PaneSpawnReservationResult
}
return response
}
/** Records the terminal a runtime create started in the pane, for a view waiting on that create. */
export function noteRuntimePaneCreateResult(
ownerKey: string | null | undefined,
result: PaneSpawnReservationResult
): void {
const claim = ownerKey ? pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) : undefined
if (claim) {
claim.result = result
}
}
+2
View File
@@ -178,6 +178,8 @@ export type StablePaneSpawnContext = {
onOwnerReresolved?: (owner: StablePaneOwner) => 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(
@@ -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) {
@@ -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()
}
})
})
@@ -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
@@ -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 }
}
@@ -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
@@ -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<LayoutApplyOutcome> {
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<Layou
function changeIn(
hostId: ExecutionHostId,
command: LayoutCommand,
context: TerminalTopologyCommitContext
context: TerminalTopologyCommitContext,
live?: LayoutLiveContext
): PartitionChange {
const { editorViews } = context
return {
@@ -109,7 +133,8 @@ function changeIn(
hostId,
context.getSession(hostId),
command,
editorViews && partitionEditorViewsOf(editorViews, hostId)
editorViews && partitionEditorViewsOf(editorViews, hostId),
live
)
}
}
@@ -2,8 +2,10 @@ import type {
CommandOf,
LayoutApplyOutcome,
LayoutCommand,
LayoutEffects,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import type { LayoutLiveContext } from './terminal-pane-admission'
import {
resolveHostId,
type SessionHostPartitionOperations
@@ -73,28 +75,51 @@ export class LayoutCommandPersistence {
this[layoutCommandPersistenceContext] = { runtime, sessions, writeLater }
}
/** Applies to each partition holding the command's tab, routed partition first (local if none). */
/**
* Applies to each partition holding the command's tab, routed partition first (local if none).
* `onEffects` gets the routed partition's effects once the change is written; the reply never
* carries them.
*/
applyLayoutCommand(
command: LayoutCommand,
hostIds: readonly string[] = []
hostIds: readonly string[] = [],
run: {
live?: LayoutLiveContext
stopBeforeWrite?: (ptyIds: readonly string[]) => boolean
onEffects?: (effects: LayoutEffects) => void
} = {}
): Promise<LayoutApplyOutcome> {
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<LayoutApplyOutcome> =
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,
@@ -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'
})
})
})
@@ -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<T> = {
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<LayoutContext, 'isBindingLive' | 'retiresBinding'>
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<LayoutRefusalCode | null> {
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<LayoutApplyOutcome> {
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<LayoutApplyOutcome> {
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 }
@@ -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<LayoutApplyOutcome> {
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)
)
@@ -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<RuntimeMobileSessionCreateTerminalResult> {
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<typeof makeWorkspaceSessionWithHeadlessTerminal>
/** 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()
})
})
@@ -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<Parameters<Store['applyLayoutCommand']>[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(),
@@ -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()
})
})
+3 -3
View File
@@ -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.
+12 -6
View File
@@ -32,6 +32,10 @@ export type WindowLayoutCommand =
| { type: 'setDefaultTabsApplied' | 'applyDefaultTabs' }
))
export type LayoutCommandRunOptions = NonNullable<
Parameters<NonNullable<RuntimeStore['applyLayoutCommand']>>[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<LayoutApplyReply> {
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).
@@ -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(
@@ -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<string, SleepingAgentSessionRecord> = { '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)
}
})
})
@@ -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]
}
@@ -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<MobileSessionNavigation['resolvePaneOwnedResumeStartup']>
): ReturnType<MobileSessionNavigation['resolvePaneOwnedResumeStartup']>
protected abstract commitWorkspaceSessionEdit(
...args: Parameters<MobileSessionNavigation['commitWorkspaceSessionEdit']>
): ReturnType<MobileSessionNavigation['commitWorkspaceSessionEdit']>
abstract subscribeWorkspaceLayouts(
listener: Parameters<WorkspaceLayoutStream['subscribe']>[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<NewerDaemonReopenResult> {
// 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 (
@@ -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)
})
})
@@ -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<void> {
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' }
}
}
})
}
@@ -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
@@ -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()
})
})
@@ -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<StartedPty | null>
const STARTING_COMMANDS: ReadonlySet<LayoutCommand['type']> = 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<LayoutCommand, 'type'>): 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<LayoutCommand, 'type' | 'workspace'>,
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(() => {})
}
}
@@ -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<StartedPty> => {
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<string>()
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)
@@ -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<string, Promise<StartedPty | null>>()
/** Each pane's last started terminal: its bind write can land after the start resolves. */
private readonly started = new Map<string, StartedPty>()
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 (
+5 -1
View File
@@ -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<void>
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
@@ -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 })
@@ -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 } : {}),
@@ -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) })
@@ -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)
@@ -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 () => {
@@ -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(
@@ -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
@@ -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',
@@ -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<void> {
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'")
})
@@ -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)
@@ -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)
})
})
@@ -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<LayoutApplySendModule>()),
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<StoreState>): void {
mockStoreState = { ...mockStoreState, ...patch }
for (const listener of storeSubscribers) {
listener(mockStoreState)
}
}
function layoutBoundTo(ptyId: string): Partial<StoreState> {
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<void> {
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<undefined>((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')
})
})
@@ -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<StoreState>): 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)
@@ -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,
@@ -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<string, unknown>[] = []
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) })
@@ -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<typeof vi.fn>).mock.calls.map(
([data]) => data as string
)
@@ -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<typeof connectPanePty>
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<void> => {
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 () => {
@@ -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
@@ -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<string, unknown>[] = []
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<string, unknown>[] {
})
}
/**
* 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<void> {
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.
@@ -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<void> {
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<LayoutApplyTestTransport> {
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<void> {
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<string, TerminalLayoutSnapshot> }
/** 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 } }
}
}
}
@@ -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()
}
@@ -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<string | null> => {
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<string | null> =>
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 = {}
@@ -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<void> {
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<HeldPaneReopenOutcome> {
// 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)
@@ -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 () => {
@@ -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'",
@@ -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'
@@ -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,
@@ -36,14 +36,17 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
return { promise, resolve }
}
function createSession(connect: () => Promise<unknown>) {
/** 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<unknown>, 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<unknown>()
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<string | null>()
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<unknown>()
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)
})
})
@@ -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<string | null>
): Promise<string | null> {
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
}
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
type Starts = Record<string, { status: string; reason?: string }>
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'
)
})
})
@@ -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<ConnectPanePtySession>()
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<ConnectPanePtySession, PaneStartWait>()
/** 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<typeof useAppStore.getState>
): 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'}`
)
}
}
@@ -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()
}
}
@@ -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<void> {
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()
})
})
@@ -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
})
}
@@ -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<string, unknown> = {}): 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)
@@ -9,6 +9,7 @@ export type ReattachPayloadSession = Pick<
| 'clearSleepingRecordAfterColdRestoreSpawn'
| 'consumeRestoredViewportBlankingMarker'
| 'createReattachGridPush'
| 'deps'
| 'isPaneOnAlternateScreen'
| 'kittyKeyboardModes'
| 'markHiddenOutputRestoreNeeded'
@@ -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<string, unknown> = {}): 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()
})
})
@@ -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)
@@ -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)
@@ -78,6 +78,7 @@ export function installSessionReconcileDispose(session: ConnectPanePtySession):
session.consumeHibernatedAgentWake()
consumeRestartReplacement(session)
session.resumeDeferredSpawnOnReveal()
session.mainPaneStartOnReveal?.()
session.requestKnownWindowsShiftEnterReconfirmation()
session.sampleVisiblePaneForegroundAgent()
},
@@ -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<typeof useAppStore.getState>,
@@ -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
@@ -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)
})
}
@@ -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
@@ -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<void> {
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<typeof restoreTerminalPaneLayout>[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()
})
})
@@ -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,
@@ -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<typeof useAppStore.getState>
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<typeof useAppStore.getState>
// 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.
@@ -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 []
}
@@ -307,9 +307,15 @@ export function runtimeRpcClientWithLayoutApply(actual: RpcClientModule): RpcCli
}
return actual.callRuntimeRpc<TResult>(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
@@ -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 }
])
})
@@ -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
})
})
@@ -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
})
}
}
+6
View File
@@ -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'
}
+5
View File
@@ -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 }

Some files were not shown because too many files have changed in this diff Show More