mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 commita646d89b26. * 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 commit420e605ad0. * 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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+14
-4
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+17
-11
@@ -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 (
|
||||
|
||||
@@ -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) })
|
||||
|
||||
+36
@@ -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)
|
||||
|
||||
+8
-1
@@ -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 () => {
|
||||
|
||||
+24
-3
@@ -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',
|
||||
|
||||
+54
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+264
@@ -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) })
|
||||
|
||||
+27
-3
@@ -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
|
||||
)
|
||||
|
||||
+29
-4
@@ -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 () => {
|
||||
|
||||
+57
@@ -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
|
||||
|
||||
|
||||
+72
-1
@@ -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.
|
||||
|
||||
+134
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -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()
|
||||
}
|
||||
|
||||
+11
-9
@@ -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 = {}
|
||||
|
||||
+52
-20
@@ -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)
|
||||
|
||||
+13
-7
@@ -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 () => {
|
||||
|
||||
+16
@@ -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'",
|
||||
|
||||
+2
-4
@@ -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
-9
@@ -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,
|
||||
|
||||
+25
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
+47
@@ -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
|
||||
})
|
||||
}
|
||||
+8
-1
@@ -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'
|
||||
|
||||
+39
-3
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
+22
-1
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user