Layout groundwork (switched off): every question about which pane holds a terminal asks main's layout (#32002)

* refactor(runtime): every reader of a terminal's pane goes through paneOfPty (L80)

With the layout-owner switch on, main's layout answers which tab and pane hold a terminal, and
nothing reads or writes the PTY record's tabId/paneKey copies or their graph stamp. Switch-off
reads the copies exactly as before.

* refactor(runtime): read a terminal's pane through the runtime's paneOfPty method

* fix(runtime): main's pane index answers paneOfPty before any window graph

A headless host never installs a window graph, so the query asked a publisher that did not exist
and every terminal read as paneless. The index is now made on first query; it publishes only once a
graph is installed, as before.

* test(runtime): pin that a query with no window publishes nothing

* test(runtime): a terminal summary reads its pane once (L80)

* feat(runtime): pin bind-first at registration, and one own-handle rule with or without a window (switch-on only)

* test(runtime): census and tests that every create path binds its pane before it registers (L80)

* test(runtime): a bind-first fixture that stands in for the spawn commit (L80)

* test(runtime): fixtures bind a terminal's pane before registering it, as the spawn commit does (L80)

Also, with the switch on: skip the bind-first check for a paired server's workspace, release a
closed tab's terminal ownership once main has dropped the pane, and publish a pane's agent status
from the terminal main's layout binds it to.

* feat(runtime): an exit after main's close still finds the pane it retires (switch-on only, L80 Q2)

Main's layout write records every pane it drops whose terminal no pane holds any more, in a new
optional session field closedTerminalPanesByPaneKey (same TTL and cap as closed-tab records).
Exit cleanup reads main's layout first, then that record by terminal id and incarnation, and
removes the entry it used. Older builds ignore the field.

* refactor(layout): one dropped-leaf step every live layout edit runs (switch-on only, L80 Q2)

The step receives each dropped pane (host, worktree, tab, leaf, terminal, incarnation), computed
across the whole layout so a moved pane is not dropped, and runs only for a commit that declares a
live edit. Today it records the closed-pane entries; a drop is recorded once per terminal.

* fix(runtime): a phone or headless tab close still stops and releases its live terminals after main's layout drops the panes (switch-on only, L80)

The headless close reads each row's live terminal before main's close drops the panes it is found
by, and a renderer-retired tab releases the owned terminal main left in no pane, as the explicit
close already did. Close tests now commit through main's layout and bind panes first.

* fix(runtime): a local workspace's terminal tab left in a paired server's partition is main's pane (switch-on only, L257)

Older builds could save a local worktree's tab rows into a runtime partition (#18202); its terminals
are local, so main's index takes those tabs for workspaces the router homes locally. A tab or
terminal the local partition also holds is indexed once, from there; SSH and server-owned
workspaces are skipped as before.

* test(layout): the legacy-close comparison leaves out the closed-pane record only layout-owner builds write (L80)

* test(pty): a pane whose failed start withdrew its tab resolves to terminal_not_found in layout-owner builds (L80 #7)

Main's layout no longer holds the pane, so there is no pane to answer for; window-owned builds keep
the disconnected stub from the exited terminal's record.

* test(runtime): orca-runtime suites bind panes first, as the real store and spawn commit do (L80)

Fake PTY spawns commit the binding into the pane the runtime wrote, stores list their partitions,
and SSH sessions sit in their ssh:<conn> partition; terminals registered directly bind first.

* fix(runtime): main's layout names a pane's terminal only when the workspace and stamped incarnation match (switch-on only, L80)

A record made before its adoption registers (a host restart) or a local id reused after one
matched a saved binding by id alone, so a phone or paired desktop could be handed a second handle
for the pane before the terminal's own handle arrived and stay on it.

* test(runtime): a restored pane is only unnamed in the adoption gap, and no local record rescues a paired server's tab (L80)

* test(runtime): recovery, close and routing suites set up terminals as production does (L80)

A recoverable pane is one the host inventory dropped with no exit, live terminals register
rather than appear only in the window graph, and SSH stores route as the real Store does.

* test(runtime): suspects 3, 4 and 5 bind, register and apply as production does; PR-1's main-layout closes in the part-10 and part-11 close tests (L80)

* test(runtime): a fixture store applies main's layout commands as a profile does; PR-1's own closes in the part-05, part-08 and UUID-leaf close tests (L80)

* test(runtime): the profile-store stand-in moves to its own fixture, keeping bind-first under the line limit (L80)

* test(runtime): orphan adoption reads the pane from main's layout with the switch on, not the record copy (L80)

* test(runtime): a phone's pane close and a pinned tab close are main's layout closes with the switch on (#31571), in the no-widening and surface-close tables (L80)

* test(runtime): a paired phone close commits in main's layout with no relay ack when the switch is on (#31571) (L80)

* fix(terminal): a parked pane resumes its agent once main has closed the other pane that held the session (layout-owner build, L80 P1)

* test(runtime): the parked-pane suite's restart holder is a saved pane with no registered terminal, under the line limit (L80 P1)

* fix(terminal): a spawn commit that binds a reused local terminal id unbinds it from the saved pane of the older process (layout-owner build, L80 P2)

* test(runtime): the legacy OSC row test uses the UUID leaf main's layout holds and registers its live terminal with the switch on (L80 E5)

* test(runtime): the pty-exit echo close of a dead headless tab is expected to fail with the switch on until W4's close PR (L255)

* fix(terminal): a terminal record that arrives, changes incarnation or moves workspace after main composed its panes re-composes them, so a restored pane lists once (layout-owner build, L80)

* fix(terminal): a pane bound to a terminal a paired server runs keeps that binding and host, and is never indexed as a local spilled tab (layout-owner build, L80/L257)

* fix(terminal): a pane main owns no longer proves its terminal runs; the host's inventory and the terminal's record decide, so a dropped shell reads disconnected (layout-owner build, L80)

* fix(terminal): an SSH pane whose host says its shell ended keeps its leaf on a negative or failed code, as the window does, so recovery starts a shell in it; a stale handle learns the connected replacement (layout-owner build, L80)

* fix(agent-launch): a terminal main's layout still binds counts as running through a host listing gap, so a launch never starts a second shell in its pane (layout-owner build, L80)

* fix(terminal): a disconnected terminal main still binds stays a stop target, never reads stopped unproven, and keeps an exit wait waiting until a certified exit (layout-owner build, L80)

* test(terminal): the wait unit fixture answers the exit-settled dependency

* test(terminal): realistic fixtures for three phone-row tests: unique leaf ids per pane, and a window that draws main's own panes (layout-owner build, L80)

* fix(terminal): a stale handle learns a pane's replacement only when it was resolved for that pane, its own terminal ended, and the replacement is connected there now (layout-owner build, L80)

* test(session-tabs): switch-on variant for a window's chat placement (main's layout order rules); headless phone tab order is L283

* test(terminal-close): switch-on close reds name their ledger rows (L255, L256, L259a, L259b); a same-id respawn's commit stamps its incarnation; the crossing terminal binds first

* test(terminal): the crossing registration is refused by main's workspace fence (L256 switch-on variant); the placement test's tabs are main's saved layout

* refactor(terminal): the stale-handle guard reads an issue-time pane stamp on the handle record, not a second map; moved-pane, restart and single-reader tests (layout-owner build, L80)

* test(session-tabs): switch-on variants: the desktop's tab name is main's rename, and a mobile-created tab's live shell is the one its commit registers (layout-owner build, L80)

* fix(terminal): a re-issued handle keeps its issue-time pane while no pane holds its terminal (review)

* fix(terminal): stop targets and exit waits use main's holds check (workspace and incarnation), and any exit event settles a later wait (review)

* test(session-tabs): name the switch-on placement and split-group reds L288 and L289

* fix(terminal): an exit drops a closed pane's record in the same write that retires the pane, and an unbind folds into the binding write (layout-owner build, L80)

The exit used to remove main's closed-pane entry in a session write of its own, before the
retirement write. It now reads the entry, and the retirement write for that pane drops it,
re-checked by terminal id and incarnation. An exit that keeps the surface (relay contact lost,
intentional stop) keeps the entry until a confirmed exit or its TTL.

A reused terminal id's unbind is now a pure computation that applyPtyBinding applies through
its existing writes. Two split-pane tests still expected a -1 exit to close the pane; they now
follow the ruling that any negative code keeps it. Static-gate fixes: test fixtures and tests
use typed member access instead of Reflect, and no longer use type assertions.

* test(runtime): the shared fixture store announces its session writes and the L283 store lists its partitions, as a profile Store does; L289's test passes on that store (layout-owner build, L80)

The fixture store bindPaneFirst fills now tells write listeners of every session write, direct or
by a layout command, so main's post-commit merge runs as it does on a real Store. The notice is
not enumerable, so a test's own `{ ...store }` copy, which keeps its writes elsewhere, does not
inherit a subscription it would never fire. With it, the targeted split-group create (L289) passes
at this head, so its it.fails mark goes. L283's test runs on a store that names its partitions and
stays it.fails("L283").

* fix(terminal): recovery checks the caller's handle after the cheap guards again, so the switch-off build re-issues no handle on a refused recovery (layout-owner build, L80)

The stale-handle switch point had moved the handle lookup ahead of the pane, terminal and
workspace checks. That lookup can re-issue a handle, so a switch-off recovery refused on those
checks gained a side effect base never had. The lookup runs after them again, and the switch-on
replacement check lives in one method.

* test(runtime): the layout-owner start fixture's spawn commits the pane binding before main registers the terminal, as the host does (L80 switch-on variant)

The window-tab start test came in with the main merge after bind-first; its fake spawn returned
a terminal no pane was bound to, so registration refused it. The fixture's spawn now binds the
pane first, as every other bind-first fixture does.

* fix(terminal): a new process under a reused terminal id starts with no exit code, so an exit wait on it cannot settle on the old process's code (layout-owner build, L80)

The record of a reused id kept the earlier process's lastExitCode, and with the switch on an exit
wait settles on any recorded code once the terminal reads disconnected. A new incarnation now
clears the code and cause. Also drops the L283 test's partition-list option: the fixture store
already lists its partitions.

* test(runtime): switch-on variants for four tests the full local run never loaded (L80)

- A restored pane's host lists the terminal it started; a pane main owns vouches for none.
- The other pane holding a slept session is in main's layout, so an unverifiable holder blocks
  the resume as it does through a window.
- Main sleeps a workspace whose panes it lists itself, even with a renderer attached (#31571).
- A graph sync no longer reconnects a disconnected terminal of main's pane; the inventory decides.

* test(runtime): the phone Markdown route tests use #31667's realistic store, and the untitled cases wait on L268 with the switch on

The window's tab uses main's id (the file path), and the store lists its partitions and saves each
open editor's unified tab and group, as the window does (the fixture from #31667). With the
switch on, the three untitled cases fail on L268, main's row merge dropping the untitled flag,
which #31667 fixes; they are it.fails until A1b's history includes it.

* refactor(runtime): two merged files back under the line limit, by moving A1b's additions to their helpers (L80)

After the main merge, the holder-pane check and a closed tab's live-terminal lookup pushed two
files past the line limit. The holder check is now paneHoldsSession (main-terminal-surfaces.ts)
behind one method, and the closed tab's lookup is one parent-class method. Behaviour unchanged.

* test(runtime): the headless chat tests open the chat on the publish path, as every product path does; L283 stays it.fails for the main-owned reorder

openChat recorded the chat only in the runtime's snapshot, so main's layout never held it and the
phone's reorder was refused as an unknown tab. On the publish path main's layout holds it; the
reorder is accepted but does not survive the row rebuild after a close (L283, reopened).

* test(runtime): bind-first census follows the one spawn-commit step (#31596); the unbound-registration test keeps its spawn from the suites' stand-in commit

After the main merge both start lanes register in spawn-commit-assembly.ts, after
savePtySpawnPaneBinding (or, adopted, on the live owner's pane); the census lists that file. A
phone create now writes its pane before the spawn, so the suites' stand-in spawn commit bound it
and the test of a path that registers unbound passed for the wrong reason; that test's controller
now opts out of the stand-in commit.

* test(runtime): convert four switch-on tests the disguised full runs hid

Real switch-on runs showed four files whose fakes registered terminals unbound
or confirmed a stop without its exit. Bind first, stamp the source as main
does, and deliver the exit a confirmed stop delivers. Switch-off unchanged.

* test: type the two converted fakes instead of casting them

The PTY controller fake now takes the controller's optional pane fields, and the
switch-on store is a typed RuntimeStore, so neither needs an assertion.
This commit is contained in:
Jinwoo Hong
2026-10-22 17:28:42 -04:00
committed by GitHub
parent 589499e3bd
commit 7cabd3a8dc
233 changed files with 6674 additions and 1271 deletions
@@ -141,6 +141,12 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = {
closedTerminalTabTombstonesByTabId: {
onRepoRemoval: 'notRepoScoped',
onTransfer: 'notTransferred'
},
// As closed-tab records: removing the workspace's session rows prunes them by worktreeId, and the
// terminals they name never move with a project.
closedTerminalPanesByPaneKey: {
onRepoRemoval: 'notRepoScoped',
onTransfer: 'notTransferred'
}
} as const satisfies Record<keyof WorkspaceSessionState, SessionFieldDisposition>
@@ -0,0 +1,102 @@
// Main's closed-pane record (L80 Q2) is a session field every per-field table must place, and one
// a build that predates it drops without losing anything else.
import { readdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { windowViewSaveForMain } from './window-save-view'
import { WORKSPACE_SESSION_FIELD_DISPOSITION } from '../orca-profiles/profile-project-session-field-disposition'
import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from './restoring-sessions/session-worktree-ownership'
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../shared/workspace-session-host-field-ownership'
import { SESSION_FIELD_OWNERS } from '../../shared/workspace-layout/workspace-layout-beside'
import { WORKSPACE_SESSION_DISPOSITIONS } from '../../shared/wsl-move/wsl-move-dispositions'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { parseWorkspaceSession } from '../../shared/workspace-session-schema'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const FIELD = 'closedTerminalPanesByPaneKey'
const ROOT = path.resolve(__dirname, '../../..')
function sourceFiles(dir: string): string[] {
return readdirSync(path.join(ROOT, dir), { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts') && !/\.(test|spec)\.ts$|fixture/.test(file))
.map((file) => path.join(dir, file).split(path.sep).join('/'))
}
const session: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
closedTerminalPanesByPaneKey: {
'tab-1:leaf-1': { ptyId: 'pty-1', incarnationId: 'inc-1', worktreeId: 'repo::/w', closedAt: 1 }
}
}
describe('main’s closed-pane record', () => {
it.each([
['host-field ownership', WORKSPACE_SESSION_FIELD_OWNERSHIP],
['WSL move dispositions', WORKSPACE_SESSION_DISPOSITIONS],
['profile project dispositions', WORKSPACE_SESSION_FIELD_DISPOSITION],
['worktree reference kinds', WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND],
['layout field owners', SESSION_FIELD_OWNERS]
])('is placed in the %s table', (_name, table) => {
expect(Object.hasOwn(table, FIELD)).toBe(true)
})
it('survives this build’s session parse', () => {
const parsed = parseWorkspaceSession(session)
expect(parsed.ok && parsed.value.closedTerminalPanesByPaneKey).toEqual(
session.closedTerminalPanesByPaneKey
)
})
it('is dropped, alone, by a build that predates it', () => {
// A build before L80 reads the field as one it does not know: its session parse strips unknown
// fields and keeps the rest, as this build's does for a field from a newer one.
const { [FIELD]: records, ...rest } = session
const asOlder = { ...rest, closedTerminalPanesFromANewerBuild: records }
const parsed = parseWorkspaceSession(asOlder)
expect(parsed).toEqual(parseWorkspaceSession(rest))
expect(parsed.ok && parsed.value).not.toHaveProperty('closedTerminalPanesFromANewerBuild')
})
it('is written only by a live edit of main’s layout', () => {
// Load, restore, a peer's merge and WSL or profile moves never declare a live edit.
const declaring = [...sourceFiles('src/main'), ...sourceFiles('src/shared')].filter((file) =>
readFileSync(path.join(ROOT, file), 'utf8').includes("droppedLeafReason: 'live-edit'")
)
expect(declaring).toEqual(['src/main/persistence/terminal-topology/terminal-pane-admission.ts'])
})
it('is not written by a window save that leaves a bound tab out', () => {
const leaf = '11111111-1111-4111-8111-111111111111'
const stored: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
'repo::/w': [
{
id: 'tab-1',
ptyId: 'pty-1',
worktreeId: 'repo::/w',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: leaf },
activeLeafId: leaf,
expandedLeafId: null,
ptyIdsByLeafId: { [leaf]: 'pty-1' }
}
},
terminalPtyIncarnationsByPaneKey: { [`tab-1:${leaf}`]: 'inc-1' }
}
const save = { ...stored, tabsByWorktree: { 'repo::/w': [] }, terminalLayoutsByTabId: {} }
expect(windowViewSaveForMain(stored, save, 'local')).not.toHaveProperty(FIELD)
})
})
@@ -10,6 +10,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { placedSplitRoot } from '../terminal-topology/terminal-pane-placement-graft'
import { tabRowPtyIdAfterLeafBinding } from './terminal-tab-pty-ownership'
import type { PersistPtyBindingArgs } from './pty-binding-persistence'
import { reusedTerminalIdUnbinding } from './reused-terminal-id-unbind'
export function applyPtyBinding(
args: PersistPtyBindingArgs,
@@ -38,11 +39,18 @@ export function applyPtyBinding(
[repoId]: currentRevision + 1
}
}
const { [paneKey]: recordedIncarnation, ...otherIncarnations } =
session.terminalPtyIncarnationsByPaneKey ?? {}
// Switch point: a reused local id leaves the saved pane of the process that had it (P2, L80);
// the commit writes the unbinding with its own binding, each field once.
const unbound =
ORCA_LAYOUT_OWNER_IS_MAIN && args.incarnationId
? reusedTerminalIdUnbinding(session, { ...args, incarnationId: args.incarnationId })
: null
const stamps =
unbound?.terminalPtyIncarnationsByPaneKey ?? session.terminalPtyIncarnationsByPaneKey
const { [paneKey]: recordedIncarnation, ...otherIncarnations } = stamps ?? {}
if (args.incarnationId || (args.dropsIncarnation && recordedIncarnation)) {
session.terminalPtyIncarnationsByPaneKey = args.incarnationId
? { ...session.terminalPtyIncarnationsByPaneKey, [paneKey]: args.incarnationId }
? { ...stamps, [paneKey]: args.incarnationId }
: otherIncarnations
}
if (args.incarnationId && session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
@@ -51,7 +59,9 @@ export function applyPtyBinding(
}
delete session.terminalSurfaceTombstonesByPaneKey[paneKey]
}
const tabs = session.tabsByWorktree?.[bindingWorktreeId]
let rows = unbound?.tabsByWorktree ?? session.tabsByWorktree
let layoutsByTab = unbound?.terminalLayoutsByTabId ?? session.terminalLayoutsByTabId
const tabs = rows?.[bindingWorktreeId]
const tab = tabs?.find((t) => t.id === args.tabId)
if (tab) {
if (args.correctStartupCwd !== undefined) {
@@ -59,7 +69,7 @@ export function applyPtyBinding(
}
tab.ptyId = tabRowPtyIdAfterLeafBinding(
tab,
session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId,
layoutsByTab?.[args.tabId]?.ptyIdsByLeafId,
args.leafId,
args.ptyId
)
@@ -74,10 +84,7 @@ export function applyPtyBinding(
existingTabCount: tabs?.length ?? 0
})
]
session.tabsByWorktree = {
...session.tabsByWorktree,
[bindingWorktreeId]: nextTabs
}
rows = { ...rows, [bindingWorktreeId]: nextTabs }
session.activeWorktreeId ??= bindingWorktreeId
session.activeTabId ??= args.tabId
session.activeTabIdByWorktree = {
@@ -85,6 +92,7 @@ export function applyPtyBinding(
[bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId
}
}
session.tabsByWorktree = rows
// Why: host-initiated persist snapshots used to omit this write-once guard, so every launch or reattach treated the worktree as never having default terminals applied.
session.defaultTerminalTabsAppliedByWorktreeId = {
...session.defaultTerminalTabsAppliedByWorktreeId,
@@ -100,13 +108,10 @@ export function applyPtyBinding(
bindingWorktreeId
]
}
if (!isTerminalLeafId(args.leafId)) {
// Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration.
advanceTopologyFence()
return
}
const layout = session.terminalLayoutsByTabId?.[args.tabId]
if (layout) {
// Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration.
const leafKeyed = isTerminalLeafId(args.leafId)
const layout = layoutsByTab?.[args.tabId]
if (leafKeyed && layout) {
if (!layout.root) {
terminalMembershipChanged = true
// Why: load names every saved root, but the layout type still allows none; the binding needs a durable one.
@@ -133,11 +138,11 @@ export function applyPtyBinding(
...layout.ptyIdsByLeafId,
[args.leafId]: args.ptyId
}
} else {
} else if (leafKeyed) {
terminalMembershipChanged = true
// Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId.
session.terminalLayoutsByTabId = {
...session.terminalLayoutsByTabId,
layoutsByTab = {
...layoutsByTab,
[args.tabId]: {
root: { type: 'leaf', leafId: args.leafId },
activeLeafId: args.leafId,
@@ -146,5 +151,6 @@ export function applyPtyBinding(
}
}
}
session.terminalLayoutsByTabId = layoutsByTab
advanceTopologyFence()
}
@@ -7,6 +7,7 @@ import { bindProviderListeners } from '../../ipc/pty/provider/bind-listeners'
import { createPtyIpcSession } from '../../ipc/pty/session'
import { DaemonPtyAdapter } from '../../daemon/daemon-pty-adapter'
import { expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit'
@@ -338,12 +339,14 @@ it.each(['success', 'new-exit', 'old-exit'] as const)(
]
)
.toBeUndefined()
expect
.soft(
runtime.resolveTerminalPane(`${binding.tabId}:${binding.leafId}`, binding.worktreeId)
.connected
)
.toBe(false)
const resolve = () =>
runtime.resolveTerminalPane(`${binding.tabId}:${binding.leafId}`, binding.worktreeId)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// The failed start withdrew the tab from main's layout: there is no such pane any more.
expect.soft(resolve).toThrow('terminal_not_found')
} else {
expect.soft(resolve().connected).toBe(false)
}
} else {
expect(result.error).toBeNull()
expect(result.value?.result).toMatchObject({
@@ -0,0 +1,189 @@
// L80 (P2): a spawn commit binding a reused local terminal id leaves no other pane naming it.
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
const switchState = vi.hoisted(() => ({ on: true }))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return switchState.on
}
}))
const { applyPtyBinding } = await import('./pty-binding-session-update')
const WORKTREE = 'repo-1::/fixture/worktree'
const SAVED_LEAF = '11111111-1111-4111-8111-111111111111'
const NEW_LEAF = '22222222-2222-4222-8222-222222222222'
/** A pane saved before a restart, bound to local id `pty-3`, stamped (or not) with its process. */
function savedSession(stamp: string | null): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE]: [
{
id: 'saved-tab',
ptyId: 'pty-3',
worktreeId: WORKTREE,
title: 'Saved',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'saved-tab': {
root: { type: 'leaf', leafId: SAVED_LEAF },
activeLeafId: SAVED_LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [SAVED_LEAF]: 'pty-3' }
}
},
...(stamp ? { terminalPtyIncarnationsByPaneKey: { [`saved-tab:${SAVED_LEAF}`]: stamp } } : {})
}
}
/** The new terminal's spawn commit: id `pty-3` again, under a new incarnation, in a new tab. */
function bindNewTerminal(session: WorkspaceSessionState): void {
applyPtyBinding(
{
worktreeId: WORKTREE,
tabId: 'new-tab',
leafId: NEW_LEAF,
ptyId: 'pty-3',
incarnationId: 'inc-now'
},
session,
WORKTREE,
`new-tab:${NEW_LEAF}`
)
}
function panesBinding(session: WorkspaceSessionState, ptyId: string): string[] {
return Object.entries(session.terminalLayoutsByTabId).flatMap(([tabId, layout]) =>
Object.entries(layout.ptyIdsByLeafId ?? {})
.filter(([, bound]) => bound === ptyId)
.map(([leafId]) => `${tabId}:${leafId}`)
)
}
describe('a spawn commit binding a reused terminal id (switch on)', () => {
it('unbinds the id from a saved pane stamped with another incarnation, in the same write', () => {
switchState.on = true
const session = savedSession('inc-before-restart')
bindNewTerminal(session)
expect(panesBinding(session, 'pty-3')).toEqual([`new-tab:${NEW_LEAF}`])
expect(session.terminalPtyIncarnationsByPaneKey).toEqual({ [`new-tab:${NEW_LEAF}`]: 'inc-now' })
// The saved pane stays, bound to nothing, and its tab row no longer names the id.
expect(session.terminalLayoutsByTabId['saved-tab']?.root).toEqual({
type: 'leaf',
leafId: SAVED_LEAF
})
expect(session.tabsByWorktree[WORKTREE]?.find((tab) => tab.id === 'saved-tab')?.ptyId).toBe(
null
)
})
it('leaves every saved binding as it was for a spawn of an id no pane names', () => {
switchState.on = true
const session = savedSession('inc-before-restart')
const before = structuredClone(session)
applyPtyBinding(
{
worktreeId: WORKTREE,
tabId: 'new-tab',
leafId: NEW_LEAF,
ptyId: 'pty-4',
incarnationId: 'inc-4'
},
session,
WORKTREE,
`new-tab:${NEW_LEAF}`
)
expect(session.terminalLayoutsByTabId['saved-tab']).toEqual(
before.terminalLayoutsByTabId['saved-tab']
)
expect(session.tabsByWorktree[WORKTREE]?.[0]).toEqual(before.tabsByWorktree[WORKTREE]?.[0])
expect(session.terminalPtyIncarnationsByPaneKey).toEqual({
[`saved-tab:${SAVED_LEAF}`]: 'inc-before-restart',
[`new-tab:${NEW_LEAF}`]: 'inc-4'
})
})
it('moves the tab row to the pane still bound when the stale pane is a split of the same tab', () => {
switchState.on = true
const session = savedSession('inc-before-restart')
session.terminalLayoutsByTabId['saved-tab'] = {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: SAVED_LEAF },
second: { type: 'leaf', leafId: NEW_LEAF }
},
activeLeafId: SAVED_LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [SAVED_LEAF]: 'pty-3' }
}
applyPtyBinding(
{
worktreeId: WORKTREE,
tabId: 'saved-tab',
leafId: NEW_LEAF,
ptyId: 'pty-3',
incarnationId: 'inc-now'
},
session,
WORKTREE,
`saved-tab:${NEW_LEAF}`
)
expect(panesBinding(session, 'pty-3')).toEqual([`saved-tab:${NEW_LEAF}`])
expect(session.tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBe('pty-3')
expect(session.terminalPtyIncarnationsByPaneKey).toEqual({
[`saved-tab:${NEW_LEAF}`]: 'inc-now'
})
})
it('leaves a pane stamped with the same incarnation bound', () => {
switchState.on = true
const session = savedSession('inc-now')
bindNewTerminal(session)
expect(panesBinding(session, 'pty-3').sort()).toEqual(
[`new-tab:${NEW_LEAF}`, `saved-tab:${SAVED_LEAF}`].sort()
)
})
// Known limit: a legacy save has no stamp, so nothing proves it names another process.
it('leaves an unstamped saved binding alone', () => {
switchState.on = true
const session = savedSession(null)
bindNewTerminal(session)
expect(panesBinding(session, 'pty-3').sort()).toEqual(
[`new-tab:${NEW_LEAF}`, `saved-tab:${SAVED_LEAF}`].sort()
)
})
it('changes nothing with the switch off', () => {
switchState.on = false
const session = savedSession('inc-before-restart')
bindNewTerminal(session)
expect(panesBinding(session, 'pty-3').sort()).toEqual(
[`new-tab:${NEW_LEAF}`, `saved-tab:${SAVED_LEAF}`].sort()
)
switchState.on = true
})
})
@@ -0,0 +1,77 @@
// Layout-owner builds: a local terminal id is a counter, so after a restart a new terminal can reuse
// the id a saved pane still binds. The spawn commit that binds the new terminal also unbinds the id
// from every other pane stamped with a different incarnation, so main's layout names one pane.
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
export type ReusedTerminalBinding = {
ptyId: string
incarnationId: string
tabId: string
leafId: string
}
type UnboundFields = Pick<
WorkspaceSessionState,
'terminalLayoutsByTabId' | 'terminalPtyIncarnationsByPaneKey' | 'tabsByWorktree'
>
/**
* The session's layouts, incarnation stamps and tab rows with `ptyId` dropped from each other pane
* whose stamped incarnation differs; null when none does. The commit writes them with its binding.
* An unstamped binding (a legacy save) is left alone: nothing proves it names another process.
*/
export function reusedTerminalIdUnbinding(
session: WorkspaceSessionState,
binding: ReusedTerminalBinding
): UnboundFields | null {
const stamps = session.terminalPtyIncarnationsByPaneKey ?? {}
let layouts = session.terminalLayoutsByTabId
let remainingStamps = stamps
let rows = session.tabsByWorktree
let changed = false
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const stale = Object.entries(layout.ptyIdsByLeafId ?? {}).flatMap(([leafId, ptyId]) => {
const stamp = stamps[`${tabId}:${leafId}`]
const isBindingPane = tabId === binding.tabId && leafId === binding.leafId
return ptyId === binding.ptyId &&
!isBindingPane &&
stamp !== undefined &&
stamp !== binding.incarnationId
? [`${tabId}:${leafId}`]
: []
})
if (stale.length === 0) {
continue
}
changed = true
const stillBound = Object.fromEntries(
Object.entries(layout.ptyIdsByLeafId ?? {}).filter(
([leafId]) => !stale.includes(`${tabId}:${leafId}`)
)
)
layouts = { ...layouts, [tabId]: { ...layout, ptyIdsByLeafId: stillBound } }
remainingStamps = Object.fromEntries(
Object.entries(remainingStamps).filter(([paneKey]) => !stale.includes(paneKey))
)
// The tab row's own terminal id names the stale process too; it takes a pane still bound.
const named = (tab: { id: string; ptyId?: string | null }) =>
tab.id === tabId && tab.ptyId === binding.ptyId
rows = Object.fromEntries(
Object.entries(rows).map(([worktreeId, tabs]) => [
worktreeId,
tabs.some(named)
? tabs.map((tab) =>
named(tab) ? { ...tab, ptyId: Object.values(stillBound)[0] ?? null } : tab
)
: tabs
])
)
}
return changed
? {
terminalLayoutsByTabId: layouts,
terminalPtyIncarnationsByPaneKey: remainingStamps,
tabsByWorktree: rows
}
: null
}
@@ -54,6 +54,13 @@ export function deleteScannedSessionFieldsForOwners(
}
}
}
if (next.closedTerminalPanesByPaneKey) {
for (const [paneKey, record] of Object.entries(next.closedTerminalPanesByPaneKey)) {
if (isRemovedOwner(record.worktreeId)) {
delete next.closedTerminalPanesByPaneKey[paneKey]
}
}
}
next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter(
(worktreeId) => !isRemovedOwner(worktreeId)
)
@@ -54,7 +54,8 @@ export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = {
terminalPtyIncarnationsByPaneKey: 'none',
terminalTopologyRevisionByRepoId: 'none',
terminalSurfaceTombstonesByPaneKey: 'row-record',
closedTerminalTabTombstonesByTabId: 'row-record'
closedTerminalTabTombstonesByTabId: 'row-record',
closedTerminalPanesByPaneKey: 'row-record'
} as const satisfies Record<keyof WorkspaceSessionState, WorkspaceSessionWorktreeReferenceKind>
type WorktreeRow = { worktreeId?: string | null }
@@ -81,8 +81,7 @@ const COMMANDS: LayoutCommand[] = [
createdAt: 4,
browserCreatedAt: 4
},
{ ...on, type: 'createTerminalTab', tabId: 'tab-scratch', leafId: leaf(4), createdAt: 5 },
{ ...on, type: 'closeTabs', tabIds: ['tab-scratch'], force: true, recordClose: 'user' }
{ ...on, type: 'createTerminalTab', tabId: 'tab-scratch', leafId: leaf(4), createdAt: 5 }
]
function windowSave(stored: WorkspaceSessionState): WorkspaceSessionState {
@@ -160,6 +159,21 @@ describe('the session a layout-owner build writes (downgrade probe, ledger L8)',
ptyId: 'pty-shell',
incarnationId: 'inc-shell'
})
// A closed tab keeps the terminals its panes held, for an exit after the close (L80).
await store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'tab-scratch',
leafId: leaf(4),
ptyId: 'pty-scratch',
incarnationId: 'inc-scratch'
})
await apply(store, {
...on,
type: 'closeTabs',
tabIds: ['tab-scratch'],
force: true,
recordClose: 'user'
})
minting.on = false
// The window's own save (session:set): its selection and the page its browser tab shows.
const stored = store.getWorkspaceSession()
@@ -1,3 +1,4 @@
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -57,6 +58,15 @@ function legacyOnly(change: ReturnType<typeof diffStoredSession>[number]): boole
* Known gap, the same one the record-patch test pins: the load folds this legacy row into the
* tab-bar tab whose terminal it repeats, so the stored row keeps its terminal id after the close.
*/
/** Layout-owner builds also record the closed pane's terminal for its exit (L80); the legacy close cannot. */
function layoutOwnerOnly(change: ReturnType<typeof diffStoredSession>[number]): boolean {
return (
ORCA_LAYOUT_OWNER_IS_MAIN &&
change.table === 'session' &&
change.field === 'closedTerminalPanesByPaneKey'
)
}
const FOLDED_LEGACY_ROW_CLOSES = new Map([
[
'tab-legacy',
@@ -94,7 +104,7 @@ describe('layout.apply closePane against the legacy pane close', () => {
}
expect(dark.value.ok, label).toBe(true)
const differences = diffStoredSession(legacy.session, dark.session ?? stored)
.filter((change) => !legacyOnly(change))
.filter((change) => !legacyOnly(change) && !layoutOwnerOnly(change))
.map(({ table, record, field }) => `${table}/${record}/${field}`)
expect(differences, label).toEqual(FOLDED_LEGACY_ROW_CLOSES.get(tab.id) ?? [])
}
@@ -173,7 +173,9 @@ function applyToSession(
...live,
mintId: randomUUID,
mintLeafId: randomUUID,
now: Date.now
now: Date.now,
// Every pane write here is a live edit of main's layout.
droppedLeafReason: 'live-edit'
})
return applied.ok
? {
@@ -79,6 +79,12 @@ async function placeOn(
refreshInventory: async () => ({ workspaceIds: new Set([WORKTREE]) }),
ptys: () => ptys,
isStillBinding: () => false,
// The pane each fake terminal names stands in for the host's answer.
paneOf: (pty) => ({
tabId: pty.tabId ?? null,
paneKey: pty.paneKey ?? null,
recordedAtGraphSequence: 0
}),
hostIds: () => store.getWorkspaceSessionHostIds(),
session: (hostId) => store.getWorkspaceSession(hostId),
apply: (command) => store.applyLayoutCommand(command),
@@ -192,6 +192,11 @@ const REFERENCE_FIXTURES: Partial<Record<SessionField, Partial<WorkspaceSessionS
},
closedTerminalTabTombstonesByTabId: {
closedTerminalTabTombstonesByTabId: { 'tab-1': { closedAt: 5, worktreeId: OLD } }
},
closedTerminalPanesByPaneKey: {
closedTerminalPanesByPaneKey: {
'tab-1:leaf': { ptyId: 'pty-1', incarnationId: 'inc-1', worktreeId: OLD, closedAt: 5 }
}
}
}
@@ -214,6 +214,15 @@ export function migrateWorktreeIdentity(
session.closedTerminalTabTombstonesByTabId = nextClosedTombstones
sessionChanged = true
}
const nextClosedPanes = repointRowRecord(
session.closedTerminalPanesByPaneKey,
oldWorktreeId,
newWorktreeId
)
if (nextClosedPanes) {
session.closedTerminalPanesByPaneKey = nextClosedPanes
sessionChanged = true
}
const nextCloseIntents = repointRowArrays(
session.clientHostedBrowserCloseIntentsByEnvironment,
oldWorktreeId,
@@ -0,0 +1,119 @@
// Switch-on stand-ins for close fixtures: main's `layout.apply` over a fake store's one session,
// and what the desktop sees of a CLI pane close that main's layout carries.
import { expect, type Mock } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree'
import type {
LayoutApplyOutcome,
LayoutCommand
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { DurableProfileStateMutation } from '../../persistence/loading-store/store-runtime-state'
import { resolveHostId } from '../../persistence/loading-store/session-host-partitions'
import { applyCommandToSession } from '../../persistence/terminal-topology/terminal-pane-admission'
import {
isTerminalClose,
keptOnWriteFailure
} from '../../persistence/terminal-topology/terminal-close-write-failure'
type SessionCell = {
get: () => WorkspaceSessionState
set: (session: WorkspaceSessionState) => void
}
/** Main's `layout.apply` on the session, applied at once. */
export function sessionLayoutApply(cell: SessionCell) {
return async (command: LayoutCommand, hostIds: readonly string[] = []) => {
const change = applyCommandToSession(resolveHostId(hostIds[0]), cell.get(), command)
if (change.session) {
cell.set(change.session)
}
return change.value
}
}
/** As the Store runs `layout.apply`: one durable write, and a close stands though it fails. */
export function durableSessionLayoutApply(
store: {
runDurableMutation: <V>(mutate: () => DurableProfileStateMutation<V>) => Promise<V>
},
cell: SessionCell
) {
return (command: LayoutCommand, hostIds: readonly string[] = []) => {
const mutate = (): DurableProfileStateMutation<LayoutApplyOutcome> => {
const before = cell.get()
const change = applyCommandToSession(resolveHostId(hostIds[0]), before, command)
if (change.session) {
cell.set(change.session)
}
return { value: change.value, rollback: () => cell.set(before) }
}
return isTerminalClose(command)
? keptOnWriteFailure(
(run) => store.runDurableMutation(run),
mutate,
() => {}
)
: store.runDurableMutation(mutate)
}
}
/** A fake store as a profile's: its host list, and the Store's durable `layout.apply`. */
export function withMainLayout<
T extends { runDurableMutation: <V>(mutate: () => DurableProfileStateMutation<V>) => Promise<V> }
>(store: T, cell: SessionCell) {
return Object.assign(store, {
getWorkspaceSessionHostIds: () => ['local'],
applyLayoutCommand: durableSessionLayoutApply(store, cell)
})
}
/**
* The desktop drops a pane a CLI close by handle ended. Switch-off the window is told to; switch-on
* main's layout close carries it, so the window-era notice goes out only for a pane main had
* already dropped, and never twice.
*/
export function expectDesktopDropsPane(
harness: {
closeTerminalPane: Mock<(...args: unknown[]) => unknown>
getSession: () => WorkspaceSessionState
},
tabId: string,
leafId: string
): void {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(tabId, leafId)
return
}
expect(collectLeafIds(harness.getSession().terminalLayoutsByTabId[tabId]?.root)).not.toContain(
leafId
)
expect(harness.closeTerminalPane.mock.calls.length).toBeLessThanOrEqual(1)
for (const call of harness.closeTerminalPane.mock.calls) {
expect(call).toEqual([tabId, leafId])
}
}
/**
* The desktop drops a tab a phone or CLI closed. Switch-off the window is told to close it;
* switch-on main closes it in its own layout (#31571) and the window draws that, unasked.
*/
export function expectDesktopDropsTab(
harness: {
closeTerminal: Mock<(...args: unknown[]) => unknown>
getSession: () => WorkspaceSessionState
},
tabId: string
): void {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(harness.closeTerminal).toHaveBeenCalledWith(tabId)
return
}
expect(harness.getSession().terminalLayoutsByTabId[tabId]).toBeUndefined()
expect(
Object.values(harness.getSession().tabsByWorktree)
.flat()
.map((tab) => tab.id)
).not.toContain(tabId)
expect(harness.closeTerminal).not.toHaveBeenCalled()
}
@@ -4,6 +4,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { RuntimeTerminalListResult } from '../../../shared/runtime-types'
import { OrcaRuntimeService } from '../orca-runtime'
import { withMainLayout } from './main-layout-close-fixture'
import {
CANARY_INCARNATION_ID,
CANARY_LEAF_ID,
@@ -114,6 +115,7 @@ function createHarness(
}
})
})
withMainLayout(store, { get: () => session, set: (next) => (session = next) })
const acknowledged = makeDeferred()
let closeTerminalTabError: Error | null = null
let closeTerminalTabAction: (() => void | Promise<void>) | null = null
@@ -298,6 +300,7 @@ async function createStaleTabCloseHarness(
return { ...harness, terminal }
}
export { expectDesktopDropsPane } from './main-layout-close-fixture'
export {
makeDeferred,
createHarness,
@@ -3,6 +3,9 @@ import { vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { TuiAgent } from '../../shared/tui-agent'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import type { RuntimeStore } from './runtime-store-contract'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { committingSpawn, registerBoundPty } from './bind-first-pane.test-fixture'
const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const TRANSCRIPT_PANE_TAB_ID = 'tab-1'
@@ -36,11 +39,37 @@ export type TranscriptPaneOptions = {
onShellForegroundProof?: () => void
}
/** Layout-owner builds: an empty store whose session holds main's layout, which binds the pane. */
function transcriptLayoutStore(): RuntimeStore {
return {
getRepos: () => [],
getRepo: () => undefined,
addRepo: vi.fn<RuntimeStore['addRepo']>(),
updateRepo: vi.fn<RuntimeStore['updateRepo']>(),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn<RuntimeStore['setWorktreeMeta']>(),
removeWorktreeMeta: vi.fn<RuntimeStore['removeWorktreeMeta']>(),
getGitHubCache: vi.fn<RuntimeStore['getGitHubCache']>(),
getSettings: () => ({
workspaceDir: '/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
}
}
export async function createTranscriptPane(
options: TranscriptPaneOptions,
runtimeDeps?: ConstructorParameters<typeof OrcaRuntimeService>[2]
): Promise<{ runtime: OrcaRuntimeService; handle: string }> {
const runtime = new OrcaRuntimeService(null, undefined, runtimeDeps)
const runtime = new OrcaRuntimeService(
ORCA_LAYOUT_OWNER_IS_MAIN ? transcriptLayoutStore() : null,
undefined,
runtimeDeps
)
// The runtime reads a remote pane's host OS from its worktree path.
const worktreeId = options.remoteWindowsHost
? 'repo-1::C:\\repo\\app'
@@ -57,7 +86,13 @@ export async function createTranscriptPane(
})
const processInspection = options.processInspection
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }),
spawn: committingSpawn(
runtime,
vi.fn(async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => ({
id: TRANSCRIPT_PANE_PTY_ID,
incarnationId: 'inc-1'
}))
),
write: () => true,
kill: () => true,
getSize: () => options.size ?? null,
@@ -110,29 +145,46 @@ export async function createTranscriptPane(
title: 'Terminal'
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId,
title: 'Terminal',
activeLeafId: TRANSCRIPT_PANE_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId,
leafId: TRANSCRIPT_PANE_LEAF_ID,
paneRuntimeId: 1,
ptyId: TRANSCRIPT_PANE_PTY_ID,
paneTitle: options.paneTitle
}
]
})
runtime.syncWindowGraph(
1,
ORCA_LAYOUT_OWNER_IS_MAIN
? {
// A layout-owner window reports only what it shows of main's panes.
tabs: [],
leaves: [],
mountedLeaves: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
leafId: TRANSCRIPT_PANE_LEAF_ID,
paneRuntimeId: 1,
paneTitle: options.paneTitle
}
]
}
: {
tabs: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId,
title: 'Terminal',
activeLeafId: TRANSCRIPT_PANE_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId,
leafId: TRANSCRIPT_PANE_LEAF_ID,
paneRuntimeId: 1,
ptyId: TRANSCRIPT_PANE_PTY_ID,
paneTitle: options.paneTitle
}
]
}
)
if (options.launchAgent) {
runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, worktreeId, options.connectionId ?? null, {
registerBoundPty(runtime, TRANSCRIPT_PANE_PTY_ID, worktreeId, options.connectionId ?? null, {
tabId: TRANSCRIPT_PANE_TAB_ID,
leafId: TRANSCRIPT_PANE_LEAF_ID,
incarnationId: 'inc-1',
@@ -0,0 +1,335 @@
// Layout-owner builds bind a terminal's pane before they register it (L80): the PTY host's spawn
// commit writes the binding, then registers. Fixtures that register terminals themselves, or fake
// the spawn, stand in for that commit here. Window-owned builds write nothing, as before.
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getConnectionExecutionHostId } from '../../shared/execution-host'
import { getPtyExecutionHost } from '../../shared/terminal-execution-host'
import { asProfileSessionStore, storeOf } from './profile-session-store.test-fixture'
import { reusedTerminalIdUnbinding } from '../persistence/loading-store/reused-terminal-id-unbind'
import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization'
import type { OrcaRuntimeService } from './orca-runtime'
export { boundSessionOf, forgetInstalledPaneSession } from './profile-session-store.test-fixture'
export type BoundPane = {
worktreeId: string
tabId: string
leafId: string
ptyId: string
/** The new tab's view mode, as the creating client saved it. */
viewMode?: 'chat' | 'terminal'
/** The process the spawn commit stamps on the pane. */
incarnationId?: string
}
/** The pane's incarnation stamped, and a reused id unbound from older panes, as the commit does. */
function stampedSession(
session: WorkspaceSessionState,
pane: BoundPane,
incarnationId: string
): WorkspaceSessionState {
const next = { ...session, ...reusedTerminalIdUnbinding(session, { ...pane, incarnationId }) }
return {
...next,
terminalPtyIncarnationsByPaneKey: {
...next.terminalPtyIncarnationsByPaneKey,
[`${pane.tabId}:${pane.leafId}`]: incarnationId
}
}
}
/** The session with the pane bound to its terminal: the tab and leaf added when missing. */
export function withBoundPane(
given: WorkspaceSessionState,
pane: BoundPane
): WorkspaceSessionState {
const session = pane.incarnationId ? stampedSession(given, pane, pane.incarnationId) : given
const tabs = session.tabsByWorktree[pane.worktreeId] ?? []
const hasTab = tabs.some((tab) => tab.id === pane.tabId)
const layout = session.terminalLayoutsByTabId[pane.tabId]
const leaf = { type: 'leaf' as const, leafId: pane.leafId }
const root = !layout?.root
? leaf
: layoutContainsLeafId(layout.root, pane.leafId)
? layout.root
: { type: 'split' as const, direction: 'vertical' as const, first: layout.root, second: leaf }
return {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[pane.worktreeId]: hasTab
? tabs
: [
...tabs,
{
id: pane.tabId,
ptyId: pane.ptyId,
worktreeId: pane.worktreeId,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: tabs.length,
createdAt: 1,
...(pane.viewMode ? { viewMode: pane.viewMode } : {})
}
]
},
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[pane.tabId]: {
...layout,
activeLeafId: layout?.activeLeafId ?? pane.leafId,
expandedLeafId: layout?.expandedLeafId ?? null,
root,
ptyIdsByLeafId: { ...layout?.ptyIdsByLeafId, [pane.leafId]: pane.ptyId }
}
}
}
}
/**
* As the spawn commit: binds the pane in the runtime's store session before registration. A
* store with no session gets an in-memory one, as a profile always has. No-op with the switch off.
*/
export function bindPaneFirst(
runtime: OrcaRuntimeService,
pane: BoundPane,
hostId = 'local'
): void {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
return
}
const store = storeOf(runtime)
if (!store) {
throw new Error('bindPaneFirst needs a runtime with a store: main binds panes in its layout')
}
const session = asProfileSessionStore(store, hostId)
const current = session.get(hostId)
session.set(withBoundPane(current, pane), hostId)
}
/** As main's pane move to a new tab: the pane leaves `fromTabId` (a tab left empty goes) first. */
export function movePaneFirst(
runtime: OrcaRuntimeService,
fromTabId: string,
pane: BoundPane,
hostId = 'local'
): void {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
return
}
const store = storeOf(runtime)
const session = store?.getWorkspaceSession?.(hostId)
if (!store?.setWorkspaceSession || !session) {
throw new Error('movePaneFirst needs a pane bindPaneFirst bound')
}
const { [fromTabId]: _left, ...layouts } = session.terminalLayoutsByTabId
store.setWorkspaceSession(
{
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[pane.worktreeId]: (session.tabsByWorktree[pane.worktreeId] ?? []).filter(
(tab) => tab.id !== fromTabId
)
},
terminalLayoutsByTabId: layouts
},
hostId
)
bindPaneFirst(runtime, pane, hostId)
}
/** registerPty as the PTY host's commit runs it: the pane is bound first. */
export function registerBoundPty(
runtime: OrcaRuntimeService,
ptyId: string,
worktreeId: string,
connectionId: string | null,
binding: { tabId: string; leafId: string } & Record<string, unknown>
): void {
const { incarnationId } = binding
bindPaneFirst(runtime, {
worktreeId,
tabId: binding.tabId,
leafId: binding.leafId,
ptyId,
...(typeof incarnationId === 'string' ? { incarnationId } : {})
})
runtime.registerPty(ptyId, worktreeId, connectionId, binding)
}
/**
* A live terminal in a layout-owner build: bound and registered, as the spawn commit or a daemon
* reattach leaves it. Window-owned suites prove liveness through the window graph, so this is a no-op.
*/
export function registerLivePty(
runtime: Parameters<typeof registerBoundPty>[0],
ptyId: string,
worktreeId: string,
connectionId: string | null,
binding: { tabId: string; leafId: string } & Record<string, unknown>
): void {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
registerBoundPty(runtime, ptyId, worktreeId, connectionId, binding)
}
}
/** A fake PTY host spawn that commits as the real one does: it binds the pane, then answers. */
export function committingSpawn<
Args extends { worktreeId?: string; tabId?: string; leafId?: string },
Result extends { id: string }
>(
runtime: OrcaRuntimeService,
spawn: (args: Args) => Promise<Result>,
hostId = 'local'
): (args: Args) => Promise<Result> {
return async (args) => {
const result = await spawn(args)
if (args.worktreeId && args.tabId && args.leafId && typeof result?.id === 'string') {
bindPaneFirst(
runtime,
{
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId,
ptyId: result.id
},
hostId
)
}
return result
}
}
/**
* As the PTY host's commit, for a store with sessions: binds the pane the runtime wrote before the
* spawn, and refuses one closed since. A store that cannot write panes gets the one it would have.
*/
function commitIntoWrittenPane(
runtime: OrcaRuntimeService,
args: SpawnArgs,
result: unknown
): void {
const ptyId = result && typeof result === 'object' && 'id' in result ? result.id : null
const store = storeOf(runtime)
if (
!store?.getWorkspaceSession ||
!args.worktreeId ||
!args.tabId ||
!args.leafId ||
typeof ptyId !== 'string'
) {
return
}
const { worktreeId, tabId, leafId } = args
const holdsPane = (hostId: string): boolean => {
const session = store.getWorkspaceSession?.(hostId)
return (
(session?.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) ?? false) &&
layoutContainsLeafId(session?.terminalLayoutsByTabId?.[tabId]?.root ?? null, leafId)
)
}
// The partition the runtime wrote the pane in; with no admission, the one bindPaneFirst uses.
const hostId = !Reflect.has(store, 'admitTerminalPane')
? 'local'
: [getConnectionExecutionHostId(args.connectionId), 'local'].find(holdsPane)
if (hostId) {
bindPaneFirst(runtime, { worktreeId, tabId, leafId, ptyId }, hostId)
}
}
type SpawnArgs = Parameters<NonNullable<RuntimePtyController['spawn']>>[0]
type PtyControllerRuntimePrototype = {
setPtyController: (this: OrcaRuntimeService, controller: RuntimePtyController | null) => void
}
// Controllers a test keeps from the stand-in commit, to drive a path that registers unbound.
const uncommittedControllers = new WeakSet<RuntimePtyController>()
/** Keeps commitFakeSpawnsInRuntimeTests from binding this controller's spawns. */
export function leaveSpawnsUncommitted(controller: RuntimePtyController): RuntimePtyController {
uncommittedControllers.add(controller)
return controller
}
/**
* Gives every fake PTY controller a suite installs the real controller's spawn commit, which binds
* the pane before the runtime registers it. A spawn already committing binds the same pane again.
*/
export function commitFakeSpawnsInRuntimeTests(prototype: PtyControllerRuntimePrototype): void {
const setPtyController = prototype.setPtyController
prototype.setPtyController = function (controller) {
// No spawn, nothing to commit: the suite's own object, so its methods see themselves as `this`.
if (typeof controller?.spawn !== 'function' || uncommittedControllers.has(controller)) {
setPtyController.call(this, controller)
return
}
const commit = (args: SpawnArgs, result: unknown): void =>
commitIntoWrittenPane(this, args, result)
// An accessor on the suite's own object, not a copy: a suite that swaps its spawn later
// (a spy, a reassignment) still has its new spawn committed.
type Spawn = NonNullable<RuntimePtyController['spawn']>
const committed = new WeakMap<Spawn, Spawn>()
let current: Spawn = controller.spawn
Object.defineProperty(controller, 'spawn', {
configurable: true,
enumerable: true,
get: () => {
const own = current
let wrapped = committed.get(own)
if (!wrapped) {
wrapped = async (args) => {
const result = await own.call(controller, args)
commit(args, result)
return result
}
committed.set(own, wrapped)
}
return wrapped
},
set: (next: Spawn) => {
current = next
}
})
setPtyController.call(this, controller)
}
}
/**
* A recoverable pane's terminal as production loses it: the host's inventory answers without it
* and reports no exit, so the record disconnects and main keeps the pane. Window-owned builds keep
* the exit (code 0) these suites drove before.
*/
export async function dropFromHostInventory(
runtime: OrcaRuntimeService,
ptyId: string
): Promise<void> {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
runtime.onPtyExit(ptyId, 0)
return
}
const encoded = getPtyExecutionHost(ptyId)
const hostId = encoded && encoded !== 'foreign' ? encoded : 'local'
const controller = runtime['ptyController']
const base: RuntimePtyController = controller ?? {
write: () => false,
kill: () => false,
getForegroundProcess: async () => null
}
// Inherits the suite's controller, so its other methods keep their own `this`.
const inventory: RuntimePtyController = Object.assign(Object.create(base), {
listProcesses: async () => [],
listProcessesWithHostScope: async () => ({ processes: [], hostIds: ['local', hostId] }),
hasPty: () => false
})
runtime['ptyController'] = inventory
try {
await runtime['refreshPtyWorktreeRecordsWithControllerInventory']([])
} finally {
runtime['ptyController'] = controller
}
}
@@ -42,6 +42,7 @@ import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
import type * as PtyControllerContract from '../runtime-pty-controller-contract'
import type { RuntimeClientEventBus } from '../runtime-client-event-bus'
import type { RuntimeRepoSelector } from '../runtime-repo-selector'
import type { PtyPane, PtyPaneSource } from '../terminal/surface/pty-pane-query'
export abstract class RuntimeConstructionServices {
abstract getOrchestrationDb(): OrchestrationDb
@@ -109,6 +110,8 @@ export abstract class RuntimeConstructionServices {
ptyId: string
): Promise<RuntimeVisibleTerminalState | null>
protected abstract getLeafKey(tabId: string, leafId: string): string
/** The tab and pane that hold a terminal: the one reader of a record's pane copies (L80). */
protected abstract paneOfPty(pty: PtyPaneSource | null | undefined): PtyPane
protected abstract getLivePtyForHandle(handle: string): {
record: TerminalHandleRecord
pty: RuntimePtyWorktreeRecord
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
import { preserveTerminalRetirementProofs } from '../mobile-session-terminal-retirement-proof'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { startedMainTerminalSurfaces } from '../terminal/surface/main-terminal-surfaces'
import { mainBindsUnendedTerminal } from '../terminal/surface/main-pane-verdict'
import {
headlessMobileSnapshotContentUnchanged,
mobileSnapshotIdentityUnchanged
@@ -393,6 +394,10 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic
...this.tuiIdleEvidenceSource,
defaultTimeoutMs: TUI_IDLE_DEFAULT_TIMEOUT_MS,
getLivePty: (handle) => this.getLivePtyForHandle(handle),
// An exit event of any code settles the wait, as the switch-off rule did.
exitUnsettled: (pty) =>
pty.lastExitCode === null &&
mainBindsUnendedTerminal(this, pty.ptyId, this.ptyLivenessVerdictByPtyId.get(pty.ptyId)),
getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle),
startVisibleReadProbe: (waiter, waiterTimeoutMs, agent) =>
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs, agent)
@@ -112,6 +112,7 @@ export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWith
getDb: () => this.getOrchestrationDbIfAvailable(),
getLeaves: () => this.leaves.values(),
getPtys: () => this.ptysById.values(),
paneOf: (pty) => this.paneOfPty(pty),
issueLeafHandle: (leaf) => this.issueHandle(leaf),
issuePtyHandle: (pty) => this.issuePtyHandle(pty),
makePaneKey: (leaf) => this.makeRuntimePaneKey(leaf),
@@ -48,15 +48,17 @@ const cleanExit = {
freshSpawn: false,
inputSeen: true,
outputSeen: true,
shown: true,
endVouched: true
shown: true
}
const right = { worktreeId: WORKTREE, parentTabId: TAB, leafId: RIGHT, ptyId: 'pty-right' }
describe('keepsExitedPane', () => {
it('leaves a split pane whose shell exited cleanly, or that main stopped, to the retirement', () => {
it('leaves a split pane whose shell exited cleanly to the retirement', () => {
expect(keepsExitedPane(splitSession(), right, cleanExit)).toBe(false)
expect(keepsExitedPane(splitSession(), right, { ...cleanExit, exitCode: -1 })).toBe(false)
})
it('keeps a split pane on a negative code, host-confirmed or not, for recovery', () => {
expect(keepsExitedPane(splitSession(), right, { ...cleanExit, exitCode: -1 })).toBe(true)
})
it('keeps a split pane whose process failed behind its card', () => {
@@ -1,6 +1,7 @@
// #23428: a floating serve- PTY whose cwd is inside a folder workspace must stay floating.
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { committingSpawn } from './bind-first-pane.test-fixture'
// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures.
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
@@ -20,12 +21,22 @@ function makeRuntime() {
getSettings: () => ({ ...store.getSettings(), floatingTerminalCwd: HOME_FOLDER })
}
const live = new Set<string>()
const spawn = vi.fn(async (args: { sessionId?: string }) => {
const id = args.sessionId ?? 'serve-fallback'
live.add(id)
return { id }
})
const runtime = new OrcaRuntimeService(runtimeStore)
const spawn = vi.fn(
committingSpawn(
runtime,
async (args: {
sessionId?: string
worktreeId?: string
tabId?: string
leafId?: string
}) => {
const id = args.sessionId ?? 'serve-fallback'
live.add(id)
return { id }
}
)
)
runtime.setPtyController({
spawn,
write: () => true,
@@ -16,6 +16,7 @@ import type {
RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import { OrcaRuntimeService } from './orca-runtime'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
@@ -131,30 +132,41 @@ function createHarness() {
const spawnedTabIdByWorktree = new Map<string, string>()
const spawn = vi.fn(
async (args: {
worktreeId: string
tabId: string
leafId: string
persistHostSessionBinding?: boolean
}) => {
const ptyId = PTY_ID_BY_WORKTREE[args.worktreeId]!
spawnedTabIdByWorktree.set(args.worktreeId, args.tabId)
// Why: only the host binding contract decides this — the test must not
// hand the runtime an ownership signal the create path did not produce.
if (args.persistHostSessionBinding) {
persistPtyBinding(args.worktreeId, args.tabId, args.leafId, ptyId)
committingSpawn(
runtime,
async (args: {
worktreeId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}) => {
const { worktreeId, tabId, leafId } = args
if (!worktreeId || !tabId || !leafId) {
throw new Error('pane spawn without a worktree, tab and leaf')
}
const ptyId = PTY_ID_BY_WORKTREE[worktreeId]!
spawnedTabIdByWorktree.set(worktreeId, tabId)
// Why: only the host binding contract decides this — the test must not
// hand the runtime an ownership signal the create path did not produce.
if (args.persistHostSessionBinding) {
persistPtyBinding(worktreeId, tabId, leafId, ptyId)
}
return { id: ptyId }
}
return { id: ptyId }
}
)
)
runtime.setPtyController({
spawn,
write: () => true,
kill: vi.fn(() => true),
stopAndWait: vi.fn(async () => true),
// As the PTY host's stop: a confirmed stop has delivered the terminal's exit first.
stopAndWait: vi.fn(async (ptyId: string) => {
runtime.onPtyExit(ptyId, 0)
return true
}),
listProcesses: vi.fn(async () => []),
getForegroundProcess: async () => null
} as never)
})
// The host runs the full desktop app: a window IS attached.
runtime.attachWindow(1)
@@ -15,53 +15,57 @@ type Runtime = ReturnType<typeof surfaceRuntime>['runtime']
it.each([
[
'that never had one',
'with no window that never had one',
(runtime: Runtime) => {
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
}
],
[
'after its window closed',
'with a window, under the same rule (L250)',
(runtime: Runtime) => {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
}
],
[
'with no window after its window closed',
(runtime: Runtime) => {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.markGraphUnavailable(1)
}
]
])(
'creates a terminal on a host with no window, %s, and answers with its live handle',
async (_name, setup) => {
const { runtime, bindPane } = surfaceRuntime()
setup(runtime)
runtime.setPtyController({
// As the PTY host's spawn commit: it binds the pane before the spawn answers.
spawn: vi.fn(async (args: { worktreeId?: string; tabId?: string; leafId?: string }) => {
await bindPane({
worktreeId: args.worktreeId!,
tabId: args.tabId!,
leafId: args.leafId!,
ptyId: 'pty-new'
})
return { id: 'pty-new' }
}),
write: () => true,
kill: () => true,
stopAndWait: async () => true,
getForegroundProcess: async () => null,
listProcesses: async () =>
['pty-a', 'pty-b', 'pty-new'].map((id) => ({ id, cwd: TEST_WORKTREE_PATH, title: 'sh' }))
})
])('creates a terminal on a host %s, and answers with its live handle', async (_name, setup) => {
const { runtime, bindPane } = surfaceRuntime()
setup(runtime)
runtime.setPtyController({
// As the PTY host's spawn commit: it binds the pane before the spawn answers.
spawn: vi.fn(async (args: { worktreeId?: string; tabId?: string; leafId?: string }) => {
await bindPane({
worktreeId: args.worktreeId!,
tabId: args.tabId!,
leafId: args.leafId!,
ptyId: 'pty-new'
})
return { id: 'pty-new' }
}),
write: () => true,
kill: () => true,
stopAndWait: async () => true,
getForegroundProcess: async () => null,
listProcesses: async () =>
['pty-a', 'pty-b', 'pty-new'].map((id) => ({ id, cwd: TEST_WORKTREE_PATH, title: 'sh' }))
})
// A subscribed phone: each row publication names its terminal's handle.
const unsubscribe = runtime.onMobileSessionTabsChanged(() => {}, 'phone')
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false,
select: false,
navigation: 'caller',
clientNavigationId: 'phone'
})
// A subscribed phone: each row publication names its terminal's handle.
const unsubscribe = runtime.onMobileSessionTabsChanged(() => {}, 'phone')
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
activate: false,
select: false,
navigation: 'caller',
clientNavigationId: 'phone'
})
unsubscribe()
expect(created.tab).toMatchObject({ status: 'ready' })
}
)
unsubscribe()
expect(created.tab).toMatchObject({ status: 'ready' })
})
@@ -4,6 +4,9 @@ import { getDefaultWorkspaceSession } from '../../shared/constants'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { bindPaneFirst } from './bind-first-pane.test-fixture'
export function registerHeadlessPlaceholderGraphSurfaceStandingTests(): void {
// #18191: a headless server publishes one empty placeholder graph at launch so status clients see
@@ -11,15 +14,20 @@ export function registerHeadlessPlaceholderGraphSurfaceStandingTests(): void {
// a graph statement every claim written without standing — a persisted replay, an inventory
// restore, a TUI-owner recovery — is contradicted by an empty leaf map that can never re-stamp it.
// The terminal then reports `orphaned: true` under a `pty:` tabId for the life of the process.
// Layout-owner builds keep no graph stamp: a terminal holds its surface exactly when main's
// layout binds it, so a restore writes that binding and no window graph retracts it.
const WORKTREE_ID = 'repo-1::/tmp/probe-worktree'
const LEAF = '33333333-3333-4333-8333-333333333333'
const PTY = 'pty-headless-restored'
function makeStore() {
let session = getDefaultWorkspaceSession()
return {
getWorkspaceSession: vi.fn(() => getDefaultWorkspaceSession()),
setWorkspaceSession: vi.fn(),
getWorkspaceSession: vi.fn(() => session),
setWorkspaceSession: vi.fn((next: WorkspaceSessionState) => {
session = next
}),
getRepos: vi.fn(() => [
{
id: 'repo-1',
@@ -59,6 +67,15 @@ export function registerHeadlessPlaceholderGraphSurfaceStandingTests(): void {
recordPtyWorktree: (ptyId: string, worktreeId: string, state: Record<string, unknown>) => void
}
internals.recordPtyWorktree(PTY, WORKTREE_ID, { connected: true })
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
bindPaneFirst(runtime, {
worktreeId: WORKTREE_ID,
tabId: 'tab-restored',
leafId: LEAF,
ptyId: PTY
})
return
}
internals.recordPtyWorktree(PTY, WORKTREE_ID, {
connected: true,
tabId: 'tab-restored',
@@ -102,6 +119,16 @@ export function registerHeadlessPlaceholderGraphSurfaceStandingTests(): void {
// such — otherwise this fix would have re-broken #18191 on every promoted host.
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// A window's graph does not outrank main's binding; main's layout dropping the pane does.
const attached = await runtime.listTerminals(`id:${WORKTREE_ID}`)
const kept = attached.terminals.find((terminal) => terminal.ptyId === PTY)
expect(kept?.orphaned).toBe(false)
expect(kept?.tabId).toBe('tab-restored')
runtime['store']?.setWorkspaceSession?.(getDefaultWorkspaceSession())
// The stub store announces no layout write; the next graph installs main's panes.
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
}
const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`)
const restored = terminals.find((terminal) => terminal.ptyId === PTY)
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NEWER_DAEMON_HOLD_ERROR } from '../../shared/newer-daemon-hold'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type * as DaemonHoldModule from '../daemon/daemon-newer-protocol-hold'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const daemonHold = vi.hoisted(() => ({ reopen: vi.fn() }))
@@ -36,7 +37,19 @@ async function heldPaneRuntime() {
)
// A paired desktop's layout stream: what main publishes is what it reads.
const desktopStream = runtime.subscribeWorkspaceLayouts(() => {})
const spawn = vi.fn(async (_args: { sessionId?: string }) => ({ id: 'pty-reopened' }))
const spawn = vi.fn(
committingSpawn(
runtime,
async (_args: {
sessionId?: string
worktreeId?: string
tabId?: string
leafId?: string
}) => ({
id: 'pty-reopened'
})
)
)
spawn.mockRejectedValueOnce(new Error(NEWER_DAEMON_HOLD_ERROR))
runtime.setPtyController({
spawn,
@@ -11,6 +11,7 @@ const {
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} = await import('./orca-runtime-test-fixtures.spec')
const { bindPaneFirst, committingSpawn } = await import('./bind-first-pane.test-fixture')
const { applyWorktreeSleepResumeRecords, captureWorktreeSleepResumeRecords } =
await import('./terminal/stop/worktree-sleep-resume-records')
@@ -27,7 +28,7 @@ function makeRuntime(initialVerdict: Verdict, holderVerdict: Verdict = null) {
)
const live = new Set<string>()
let spawned = 0
const spawn = vi.fn(async (_args: unknown) => {
const spawn = vi.fn(async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => {
const id = `host-pty-${++spawned}`
live.add(id)
return { id }
@@ -51,13 +52,15 @@ function makeRuntime(initialVerdict: Verdict, holderVerdict: Verdict = null) {
paneKey === HOLDER_PANE_KEY ? holderVerdict : verdict
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
stopAndWait: async (ptyId: string) => {
live.delete(ptyId)
// A PTY's exit clears its hook row, as the host's teardown does.
rows = []
// Why: stopAndWait resolves after the provider's exit; main's layout keeps the pane bound.
runtime.onPtyExit(ptyId, 0)
return true
},
getForegroundProcess: async () => null,
@@ -68,8 +71,10 @@ function makeRuntime(initialVerdict: Verdict, holderVerdict: Verdict = null) {
const reportAgent = (): void => {
rows = [row]
}
// The session resumed by hand in another pane whose shell outlived it.
// The session resumed by hand in another pane whose shell outlived it; main's layout holds it.
const reportHolder = (): void => {
const [tabId = '', leafId = ''] = HOLDER_PANE_KEY.split(':')
bindPaneFirst(runtime, { worktreeId: TEST_WORKTREE_ID, tabId, leafId, ptyId: 'holder-pty' })
rows = [{ ...row, paneKey: HOLDER_PANE_KEY, tabId: 'holder-tab' }]
}
const setVerdict = (next: Verdict): void => {
@@ -5,6 +5,8 @@ import { agentHookServer } from '../agent-hooks/server'
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
await import('./orca-runtime-test-lifecycle.spec')
const { TEST_REPO_ID, TEST_WORKTREE_ID, store } = await import('./orca-runtime-test-fixtures.spec')
const { bindPaneFirst, committingSpawn } = await import('./bind-first-pane.test-fixture')
const { toSshExecutionHostId } = await import('../../shared/execution-host')
const SESSION = { key: 'session_id', id: 'codex-history-session' } as const
@@ -15,11 +17,33 @@ function spyOnRecord() {
return spy
}
function makeRuntime(runtimeStore: ConstructorParameters<typeof OrcaRuntimeService>[0] = store) {
// Why a copy of the shared store: main's layout binds panes in the runtime store's session.
function makeRuntime(
runtimeStore: ConstructorParameters<typeof OrcaRuntimeService>[0] = { ...store },
sshTargetId?: string
) {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-resume' })
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
// An SSH pane's binding lives in its execution host's session.
spawn: sshTargetId
? async (args: { worktreeId?: string; tabId?: string; leafId?: string }) => {
const result = await spawn(args)
if (args.worktreeId && args.tabId && args.leafId) {
bindPaneFirst(
runtime,
{
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId,
ptyId: result.id
},
toSshExecutionHostId(sshTargetId)
)
}
return result
}
: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -82,11 +106,14 @@ describe('a phone resume from Session History', () => {
it('leaves an SSH pane to the relay that owns its status', async () => {
const record = spyOnRecord()
const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' }
const { runtime } = makeRuntime({
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined)
})
const { runtime } = makeRuntime(
{
...store,
getRepos: () => [remoteRepo],
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined)
},
'ssh-1'
)
await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
launchAgent: 'codex',
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
import { spawnedPtyEnv } from './spawned-pty-env-fixtures'
import { committingSpawn } from './bind-first-pane.test-fixture'
// #10923: every OMP user bubble ends with OSC 133 `B C D;0`. Read as the shell's command
// finishing, that retired the pane's hook authority mid-turn, which deleted its status row and
@@ -29,7 +30,10 @@ async function launchOmpPane(): Promise<{
paneKey: string
}> {
const retire = vi.fn()
const spawn = vi.fn().mockResolvedValue({ id: 'pty-omp', incarnationId: 'incarnation-1' })
const spawn = vi.fn(async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => ({
id: 'pty-omp',
incarnationId: 'incarnation-1'
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore serves the repo reads terminal creation makes; the rest of Store is unreached.
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
attestAgentHookCompatibilityAuthority: (candidate) => ({
@@ -39,7 +43,7 @@ async function launchOmpPane(): Promise<{
retireAgentHookCompatibilityAuthority: retire
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'omp'
@@ -7,6 +7,7 @@ import { AgentHookServer } from '../agent-hooks/server'
import { OrcaRuntimeService } from './orca-runtime'
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
import { spawnedPtyEnv } from './spawned-pty-env-fixtures'
import { committingSpawn } from './bind-first-pane.test-fixture'
// STA-4557: #14866 deferred command-finished retirement for OpenCode panes behind an
// async "is the foreground still OpenCode?" read. These pin the two ways a finished
@@ -57,7 +58,10 @@ async function launchOpenCodePane(options: {
retireAgentHookCompatibilityAuthority?: (paneKey: string) => void
attestAgentHookCompatibilityAuthority?: OrcaRuntimeServiceDeps['attestAgentHookCompatibilityAuthority']
}): Promise<LaunchedOpenCodePane> {
const spawn = vi.fn().mockResolvedValue({ id: options.ptyId, incarnationId: 'incarnation-1' })
const spawn = vi.fn(async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => ({
id: options.ptyId,
incarnationId: 'incarnation-1'
}))
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
attestAgentHookCompatibilityAuthority:
options.attestAgentHookCompatibilityAuthority ??
@@ -67,7 +71,7 @@ async function launchOpenCodePane(options: {
: {})
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: options.getForegroundProcess
@@ -4,6 +4,9 @@ import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { committingSpawn } from './bind-first-pane.test-fixture'
import type { RuntimeStore } from './runtime-store-contract'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -46,17 +49,43 @@ const title = (text: string): string => `\x1b]0;${text}\x07`
const programState = (state: 'working' | 'idle'): string =>
`\x1b]7501;state=${state}:app=codex\x1b\\`
function layoutStore(): RuntimeStore {
return {
getRepos: () => [],
getRepo: () => undefined,
addRepo: vi.fn<RuntimeStore['addRepo']>(),
updateRepo: vi.fn<RuntimeStore['updateRepo']>(),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn<RuntimeStore['setWorktreeMeta']>(),
removeWorktreeMeta: vi.fn<RuntimeStore['removeWorktreeMeta']>(),
getGitHubCache: vi.fn<RuntimeStore['getGitHubCache']>(),
getSettings: () => ({
workspaceDir: '/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
}
}
async function codexPaneAfterOutput(
rows: AgentStatusIpcPayload[],
frames: string[],
/** The host's verdict when a title drops the agent marker, as the hook server reports it. */
presence: 'live' | 'exited' = 'live'
): Promise<unknown> {
const runtime = new OrcaRuntimeService(null, undefined, {
getAgentStatusSnapshot: () => rows,
getAgentProviderSessionRowsForPane: () => rows,
checkHookAgentPresence: async () => presence
})
// Switch-on, main binds the pane in its layout before the terminal registers, so it needs a store.
const runtime = new OrcaRuntimeService(
ORCA_LAYOUT_OWNER_IS_MAIN ? layoutStore() : null,
undefined,
{
getAgentStatusSnapshot: () => rows,
getAgentProviderSessionRowsForPane: () => rows,
checkHookAgentPresence: async () => presence
}
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: OrcaRuntimeService defines this protected method; the spy only stubs workspace resolution.
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
@@ -69,7 +98,8 @@ async function codexPaneAfterOutput(
folderWorkspace: null
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: PTY_ID }),
// As the PTY host's spawn commit: the pane is bound before its terminal registers.
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: PTY_ID }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -10,6 +10,8 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state
import { OrcaRuntimeService } from './orca-runtime'
import { setRuntimeDesktopSurface } from './runtime-desktop-surface'
import { withDurableRuntimeStore } from './runtime-durable-store-fixture'
import { bindPaneFirst } from './bind-first-pane.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types'
import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission'
@@ -215,8 +217,20 @@ function coldRestoredRuntime(host: RestoreHost = LOCAL_HOST): {
return { runtime, sessions }
}
/** As the PTY host's spawn commit: binds the pane in the host's partition, then registers. */
function registerBound(
runtime: OrcaRuntimeService,
hostId: ExecutionHostId,
ptyId: string,
connectionId: string | null,
binding: { tabId: string; leafId: string; incarnationId: string }
) {
bindPaneFirst(runtime, { worktreeId: WORKTREE_ID, ptyId, ...binding }, hostId)
runtime.registerPty(ptyId, WORKTREE_ID, connectionId, binding)
}
function registerLeaf(runtime: OrcaRuntimeService, host: RestoreHost, leaf: 'left' | 'right') {
runtime.registerPty(host.ptyIds[leaf], WORKTREE_ID, host.connectionId, {
registerBound(runtime, host.hostId, host.ptyIds[leaf], host.connectionId, {
tabId: 'tab',
leafId: leaf === 'left' ? LEFT : RIGHT,
incarnationId: `incarnation-${leaf}`
@@ -309,15 +323,20 @@ describe('a restored terminal published before its PTY registers', () => {
publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST))
registerLeaf(runtime, LOCAL_HOST, 'left')
runtime.registerPty('pty-other', WORKTREE_ID, null, {
registerBound(runtime, LOCAL_EXECUTION_HOST_ID, 'pty-other', null, {
tabId: 'tab-other',
leafId: RIGHT,
// Main's layout keys a pane by leaf id alone and remints a repeat, so there it is a new leaf.
leafId: ORCA_LAYOUT_OWNER_IS_MAIN ? '44444444-4444-4444-8444-444444444444' : RIGHT,
incarnationId: 'incarnation-other'
})
expect(await listedSurfaces(runtime)).toEqual([
`tab::${LEFT}:ready`,
`tab::${RIGHT}:pending-handle`
`tab::${RIGHT}:pending-handle`,
// Main lists the other terminal's own tab from its layout, with or without the window's frame.
...(ORCA_LAYOUT_OWNER_IS_MAIN
? ['tab-other::44444444-4444-4444-8444-444444444444:ready']
: [])
])
})
})
@@ -480,7 +499,7 @@ describe('a surface the host retired stays retired when a restored sibling regis
getForegroundProcess: async () => null
} as never)
publishSshRendererFrame(runtime)
runtime.registerPty(SSH_PTY_Y, WORKTREE_ID, 'ssh-1', {
registerBound(runtime, SSH_HOST.hostId, SSH_PTY_Y, 'ssh-1', {
tabId: 'tab-y',
leafId: RIGHT,
incarnationId: 'incarnation-y'
@@ -489,7 +508,7 @@ describe('a surface the host retired stays retired when a restored sibling regis
await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'tab-y', { reason: 'user' })
expect(kill).toHaveBeenCalledWith(SSH_PTY_Y)
runtime.registerPty(SSH_PTY_X, WORKTREE_ID, 'ssh-1', {
registerBound(runtime, SSH_HOST.hostId, SSH_PTY_X, 'ssh-1', {
tabId: 'tab-x',
leafId: LEFT,
incarnationId: 'incarnation-x'
@@ -541,7 +560,7 @@ async function runtimeWithUnpublishedPhoneCreate(): Promise<OrcaRuntimeService>
if (channel !== 'terminal:requestTabCreate') {
return
}
runtime.registerPty('pty-phone', WORKTREE_ID, null, {
registerBound(runtime, LOCAL_EXECUTION_HOST_ID, 'pty-phone', null, {
tabId: 'tab-phone',
leafId: PHONE_LEAF,
incarnationId: 'incarnation-phone'
@@ -15,6 +15,7 @@ import { applyCommandToSession } from '../persistence/terminal-topology/terminal
import { resolveHostId } from '../persistence/loading-store/session-host-partitions'
import { withRuntimePaneAdmission } from './runtime-durable-store-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -119,7 +120,11 @@ async function createHeadlessRuntimeWithAgent(
const runtime = new DefaultTabRuntime(store, connectionId)
let spawned = 0
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: spawned++ === 0 ? AGENT_PTY_ID : `pty-${spawned}` })),
spawn: vi.fn(
committingSpawn(runtime, async () => ({
id: spawned++ === 0 ? AGENT_PTY_ID : `pty-${spawned}`
}))
),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -4,12 +4,15 @@ import {
setupLayoutOwnerRuntime as setup,
WORKTREE_ID
} from './orca-runtime-layout-owner-start-fixture'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
describe('layout-owner build: a worktree’s default tabs start in main', () => {
it('writes the tabs with their titles and colours in main, then starts each in its pane', async () => {
const { runtime, spawn, renameTerminal, session } = setup()
let spawned = 0
spawn.mockImplementation(committingSpawn(runtime, async () => ({ id: `pty-${++spawned}` })))
const handles = await createWorktreeDefaultTabTerminals(
runtime.provisioningHost(),
@@ -137,17 +137,19 @@ describe('layout-owner build: main closes an exited split pane itself', () => {
expect(panes(session())).toEqual([LEFT, RIGHT])
})
it('closes a split pane on a -1 after a stop was asked for', async () => {
// Switch-on variants (ruled behaviour): any negative code keeps the pane, as the window does;
// an asked-for close removes the pane through its own close command.
it('keeps a split pane on a -1 after a stop was asked for', async () => {
const { runtime, session } = splitRuntime()
runtime.markPtyStopRequested('pty-right')
await runtime.onPtyExit('pty-right', -1)
expect(panes(session())).toEqual([LEFT])
expect(panes(session())).toEqual([LEFT, RIGHT])
})
it('closes a split pane on a -1 the host confirmed', async () => {
it('keeps a split pane on a -1 the host confirmed, so recovery can start a shell in it', async () => {
const { runtime, session } = splitRuntime()
await runtime.onPtyExit('pty-right', -1, undefined, { hostExitConfirmed: true })
expect(panes(session())).toEqual([LEFT])
expect(panes(session())).toEqual([LEFT, RIGHT])
})
it('keeps a split pane whose exit came with no code, as when its socket closed', async () => {
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
import { NEWER_DAEMON_HOLD_ERROR } from '../../shared/newer-daemon-hold'
import { setupLayoutOwnerRuntime, WORKTREE_ID } from './orca-runtime-layout-owner-start-fixture'
import { committingSpawn } from './bind-first-pane.test-fixture'
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
@@ -105,12 +106,14 @@ describe('layout-owner build: a start main could not make keeps its pane, showin
it('shows every pane main starts as starting from before the pane is written, then clears it', async () => {
const { runtime, spawn, session } = setupLayoutOwnerRuntime()
const seen: unknown[] = []
spawn.mockImplementationOnce(async ({ tabId, leafId }) => {
// The pane is written by now: no frame shows it unbound without a status.
expect(session().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([tabId])
seen.push(runtime.startStatusOf(`${tabId}:${leafId}`))
return { id: 'pty-1' }
})
spawn.mockImplementationOnce(
committingSpawn(runtime, async ({ tabId, leafId }) => {
// The pane is written by now: no frame shows it unbound without a status.
expect(session().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([tabId])
seen.push(runtime.startStatusOf(`${tabId}:${leafId}`))
return { id: 'pty-1' }
})
)
await runtime.createTerminal(`id:${WORKTREE_ID}`)
+4 -3
View File
@@ -293,10 +293,11 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime
return { tabId: record.tabId, worktreeId: record.worktreeId }
}
const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null
const pane = this.paneOfPty(pty)
const tabId =
pty?.tabId && !pty.tabId.startsWith('pty:')
? pty.tabId
: parsePaneKey(pty?.paneKey ?? '')?.tabId
pane.tabId && !pane.tabId.startsWith('pty:')
? pane.tabId
: parsePaneKey(pane.paneKey ?? '')?.tabId
if (!pty || !tabId || tabId.startsWith('pty:')) {
return null
}
@@ -5,8 +5,10 @@ import { describe, expect, it, vi } from 'vitest'
import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture'
import { OrcaRuntimeService } from './orca-runtime'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -66,7 +68,19 @@ async function createRuntimeWithHookRows(
const readRows = statusWiring
? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()]
: (): AgentStatusIpcPayload[] => rows
const runtime = new OrcaRuntimeService(null, undefined, {
// Switch-on, main's layout answers which pane holds the PTY, so the runtime needs a store.
const store = ORCA_LAYOUT_OWNER_IS_MAIN
? {
getRepos: () => [],
getRepo: () => undefined,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getProjects: () => [],
getSettings: () => ({})
}
: null
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create and list paths read only repos, settings and the session bindPaneFirst adds.
const runtime = new OrcaRuntimeService(store as never, undefined, {
...(statusWiring
? {
onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus,
@@ -90,7 +104,7 @@ async function createRuntimeWithHookRows(
folderWorkspace: null
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: PTY_ID }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: PTY_ID }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -8,6 +8,7 @@ import { applyCommandToSession } from '../persistence/terminal-topology/terminal
import { resolveHostId } from '../persistence/loading-store/session-host-partitions'
import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture'
import { OrcaRuntimeService } from './orca-runtime'
import { committingSpawn } from './bind-first-pane.test-fixture'
import type { RuntimeNotifier } from './runtime-notifier-contract'
import type { PaneResumeOutcome } from '../../shared/workspace-layout/workspace-layout-published'
@@ -74,7 +75,8 @@ export function setupLayoutOwnerRuntime() {
})
)
runtime.setPtyController({
spawn,
// The host's spawn commit binds the pane before main registers the terminal (bind-first).
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { committingSpawn } from './bind-first-pane.test-fixture'
import { makeRuntimeStoreWithWorkspaceSession } from './orca-runtime-test-fixtures.spec'
import { getDefaultWorkspaceSession } from '../../shared/constants'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -23,7 +26,9 @@ const TRAILING_SPINNER_AT = STOP_AT + 4_534
const PLAIN_TITLE_AT = STOP_AT + 4_800
async function createRuntime(rows: AgentStatusIpcPayload[]): Promise<OrcaRuntimeService> {
const runtime = new OrcaRuntimeService(null, undefined, {
// Why a store: main's layout binds the pane in the store's workspace session.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
const runtime = new OrcaRuntimeService(runtimeStore, undefined, {
getAgentStatusSnapshot: () => rows
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveTerminalWorkspaceLaunchScope is a member of OrcaRuntimeService; the cast only exposes it to the spy.
@@ -38,7 +43,7 @@ async function createRuntime(rows: AgentStatusIpcPayload[]): Promise<OrcaRuntime
folderWorkspace: null
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: PTY_ID }),
spawn: committingSpawn(runtime, vi.fn().mockResolvedValue({ id: PTY_ID })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -24,6 +24,8 @@ import { withDurableRuntimeStore } from './runtime-durable-store-fixture'
*/
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { sessionLayoutApply } from './__fixtures__/main-layout-close-fixture'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { OrcaRuntimeService } from './orca-runtime'
@@ -112,9 +114,13 @@ function createHarness(options: { restarted?: boolean } = {}) {
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
getProjects: () => [],
getWorkspaceSession: () => session,
// As a profile store: main's layout indexes the session under its host.
getWorkspaceSessionHostIds: () => ['local'],
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
// Main's `layout.apply` on this partition, as the Store applies it.
applyLayoutCommand: sessionLayoutApply({ get: () => session, set: (next) => (session = next) }),
flushOrThrow: () => {}
})
const relayAck = makeDeferred()
@@ -207,7 +213,21 @@ function createHarness(options: { restarted?: boolean } = {}) {
closeTerminalTab,
kill,
publishRendererSnapshot,
retirePersistedTab: (tabId: string) => {
// A host close of the tab: switch-on main's layout close, switch-off the renderer's own write.
retirePersistedTab: async (tabId: string) => {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await runtime.applyLayoutCommand({
type: 'closeTabs',
workspace: WORKTREE_ID,
tabIds: [tabId],
recordClose: 'user'
})
return
}
session = makeSession([TAB_A, TAB_B].filter((id) => id !== tabId))
},
// A stale save that lacks the tab: not a close, in either build.
omitPersistedTab: (tabId: string) => {
session = makeSession([TAB_A, TAB_B].filter((id) => id !== tabId))
},
isRuntimeSessionOwned: (ptyId: string) =>
@@ -289,9 +309,13 @@ function createSplitHarness() {
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
getProjects: () => [],
getWorkspaceSession: () => session,
// As a profile store: main's layout indexes the session under its host.
getWorkspaceSessionHostIds: () => ['local'],
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
// Main's `layout.apply` on this partition, as the Store applies it.
applyLayoutCommand: sessionLayoutApply({ get: () => session, set: (next) => (session = next) }),
flushOrThrow: () => {}
})
const runtime = new OrcaRuntimeService(store as never)
@@ -373,7 +397,17 @@ function createSplitHarness() {
(
runtime as unknown as { ptysById: Map<string, { runtimeSessionOwned: boolean }> }
).ptysById.get(ptyId)?.runtimeSessionOwned === true,
retirePersistedLeaf: (leafId: string) => {
// A host close of the pane: switch-on main's layout close, switch-off the renderer's own write.
retirePersistedLeaf: async (leafId: string) => {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await runtime.applyLayoutCommand({
type: 'closePane',
workspace: WORKTREE_ID,
tabId: TAB_P,
leafId
})
return
}
session = makeSplitSession([LEAF_A, LEAF_B].filter((id) => id !== leafId))
},
relabelPersistedLeftLeaf: () => {
@@ -406,8 +440,16 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
const closing = harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_A, {
reason: 'user'
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main commits the close in its own layout; no relay ack is awaited.
await expect(closing).resolves.toMatchObject({ closed: true })
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
harness.publishRendererSnapshot([TAB_B], 2)
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_B])
return
}
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_A))
harness.retirePersistedTab(TAB_A)
await harness.retirePersistedTab(TAB_A)
harness.relayAck.resolve()
await expect(closing).resolves.toMatchObject({ closed: true })
// The renderer's prune publication arrives only after the ack (and after
@@ -423,11 +465,19 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
const closing = harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_A, {
reason: 'user'
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main commits the close in its own layout; no relay ack is awaited.
await expect(closing).resolves.toMatchObject({ closed: true })
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
harness.publishRendererSnapshot([TAB_B], 2)
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_B])
return
}
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_A))
// Production ordering: the renderer durably retires the tab and publishes
// the pruned graph BEFORE replying to the relay. The kill it dispatched has
// not produced an exit yet, so the PTY record is still connected.
harness.retirePersistedTab(TAB_A)
await harness.retirePersistedTab(TAB_A)
harness.publishRendererSnapshot([TAB_B], 2)
harness.relayAck.resolve()
await expect(closing).resolves.toMatchObject({ closed: true })
@@ -448,7 +498,7 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
// publishes the pruned graph; the PTY it killed has not exited yet (or the
// kill failed — WSL/Windows), so its record is still connected and still
// carries the paired-create ownership flags.
harness.retirePersistedTab(TAB_A)
await harness.retirePersistedTab(TAB_A)
harness.publishRendererSnapshot([TAB_B], 2)
const republished = await listParentTabIds(harness.runtime)
@@ -461,7 +511,7 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
const harness = createHarness({ restarted: true })
expect(await listParentTabIds(harness.runtime)).toEqual([TAB_A, TAB_B].sort())
harness.retirePersistedTab(TAB_A)
await harness.retirePersistedTab(TAB_A)
harness.publishRendererSnapshot([TAB_B], 1)
expect(
@@ -489,7 +539,7 @@ describe('a committed paired close stays closed while its PTY lingers', () => {
// is the only discriminator, and losing it would strand the create-recovery
// rescue: the freshly created remote tab would vanish.
harness.markCreatePending(TAB_A)
harness.retirePersistedTab(TAB_A)
harness.omitPersistedTab(TAB_A)
harness.publishRendererSnapshot([TAB_B], 2)
expect(
@@ -511,7 +561,7 @@ describe('a durably closed split leaf stays closed while its sibling survives',
// The host closes the left pane only: the parent tab persists with the
// surviving leaf, and the killed PTY has not exited yet (kill lag, or a
// failed kill on WSL/Windows), so it stays connected + paired-create owned.
harness.retirePersistedLeaf(LEAF_A)
await harness.retirePersistedLeaf(LEAF_A)
harness.publishSplitSnapshot([LEAF_B], 2)
expect(
@@ -537,7 +587,12 @@ describe('a durably closed split leaf stays closed while its sibling survives',
expect(
await listTerminalSurfaceIds(harness.runtime),
'a live relabeled leaf was retired by a stale publication'
).toEqual([`${TAB_P}::${LEAF_A}`, `${TAB_P}::${LEAF_B}`])
// Switch-on main's layout names the pane, so it lists under main's leaf id.
).toEqual(
ORCA_LAYOUT_OWNER_IS_MAIN
? [`${TAB_P}::${LEAF_B}`, `${TAB_P}::${LEAF_C}`]
: [`${TAB_P}::${LEAF_A}`, `${TAB_P}::${LEAF_B}`]
)
expect(harness.isRuntimeSessionOwned(PTY_A)).toBe(true)
expect(harness.isRuntimeSessionOwned(PTY_B)).toBe(true)
})
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { OrcaRuntimeService } from './orca-runtime'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { registerBoundPty } from './bind-first-pane.test-fixture'
vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false },
@@ -29,11 +31,22 @@ function getPty(runtime: OrcaRuntimeService, ptyId: string): RuntimePtyLaunchIde
)
}
/** Switch-on, main's layout answers which pane holds a terminal, so the runtime needs a store. */
function createRuntime(): OrcaRuntimeService {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only the session bindPaneFirst adds to this store.
return new OrcaRuntimeService(ORCA_LAYOUT_OWNER_IS_MAIN ? ({} as never) : null)
}
function paneKeyOf(runtime: OrcaRuntimeService, ptyId: string): string | null {
const pty = runtime['ptysById'].get(ptyId)
return runtime['paneOfPty'](pty).paneKey
}
describe('provider reattach launch identity', () => {
it('restores daemon-owned agent identity without minting renderer authority', () => {
const runtime = new OrcaRuntimeService(null)
const runtime = createRuntime()
runtime.registerPty('pty-reattach', WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-reattach', WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID,
@@ -43,9 +56,9 @@ describe('provider reattach launch identity', () => {
}
})
expect(paneKeyOf(runtime, 'pty-reattach')).toBe(PANE_KEY)
expect(getPty(runtime, 'pty-reattach')).toMatchObject({
incarnationId: INCARNATION_ID,
paneKey: PANE_KEY,
launchAgent: 'codex',
launchToken: null,
launchIncarnationId: null
@@ -53,9 +66,9 @@ describe('provider reattach launch identity', () => {
})
it('rejects provider identity from a different process incarnation', () => {
const runtime = new OrcaRuntimeService(null)
const runtime = createRuntime()
runtime.registerPty('pty-mismatched-reattach', WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-mismatched-reattach', WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID,
@@ -65,9 +78,9 @@ describe('provider reattach launch identity', () => {
}
})
expect(paneKeyOf(runtime, 'pty-mismatched-reattach')).toBe(PANE_KEY)
expect(getPty(runtime, 'pty-mismatched-reattach')).toMatchObject({
incarnationId: INCARNATION_ID,
paneKey: PANE_KEY,
launchAgent: null,
launchToken: null,
launchIncarnationId: null
@@ -75,9 +88,9 @@ describe('provider reattach launch identity', () => {
})
it('retires daemon launch identity when the agent command finishes', () => {
const runtime = new OrcaRuntimeService(null)
const runtime = createRuntime()
runtime.registerPty('pty-finished-reattach', WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-finished-reattach', WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID,
@@ -1,3 +1,4 @@
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import './rpc/unused-default-rpc-methods.test-fixture'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
@@ -8,6 +9,8 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state
import { OrcaRuntimeService } from './orca-runtime'
import { RpcDispatcher } from './rpc/dispatcher'
import { SESSION_TAB_METHODS } from './rpc/methods/session-tabs'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { bindPaneFirst } from './bind-first-pane.test-fixture'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -101,6 +104,21 @@ function publishGraph(
})
}
/** registerPty as the host's spawn commit runs it: main's layout on the repo's host binds the pane first. */
function registerBoundPty(
runtime: OrcaRuntimeService,
ptyId: string,
connectionId: string | null,
repo: { connectionId?: string | null; executionHostId?: string | null } = {}
): void {
bindPaneFirst(
runtime,
{ worktreeId: WORKTREE, tabId: 'tab', leafId: LEAF, ptyId },
getRepoExecutionHostId(repo)
)
runtime.registerPty(ptyId, WORKTREE, connectionId, { tabId: 'tab', leafId: LEAF })
}
describe('session tab workspace path aliases', () => {
it.each(
[
@@ -125,7 +143,7 @@ describe('session tab workspace path aliases', () => {
const ptyId = ptyConnectionId
? toAppSshPtyId(ptyConnectionId, 'pty-existing')
: 'pty-existing'
runtime.registerPty(ptyId, WORKTREE, ptyConnectionId ?? null, { tabId: 'tab', leafId: LEAF })
registerBoundPty(runtime, ptyId, ptyConnectionId ?? null, repo)
if (binding !== 'unpublished') {
publishGraph(runtime, WORKTREE, [], ptyId, binding)
} else {
@@ -192,7 +210,13 @@ describe('session tab workspace path aliases', () => {
if (ownsSession) {
const listed = await runtime.listMobileSessionTabs(`id:${ALIAS}`)
expect(listed.worktree).toBe(WORKTREE)
expect(listed.tabs).toContainEqual(expect.objectContaining({ id: TAB }))
if (ORCA_LAYOUT_OWNER_IS_MAIN && binding === 'unpublished') {
// A paired server's workspace: its terminals run on that server, which publishes them;
// no local record can stand for one, so nothing is rescued from this host's registry.
expect(listed.tabs).not.toContainEqual(expect.objectContaining({ id: TAB }))
} else {
expect(listed.tabs).toContainEqual(expect.objectContaining({ id: TAB }))
}
} else {
await expect(runtime.listMobileSessionTabs(`id:${ALIAS}`)).rejects.toThrow(
'worktree_execution_host_unresolved'
@@ -242,10 +266,12 @@ describe('session tab workspace path aliases', () => {
it.each([false, true])(
'recovers a surviving renderer PTY without pinning an empty alias (discovered during refresh: %s)',
async (duringRefresh) => {
const runtime = new OrcaRuntimeService()
// Why a store: main's layout binds the surviving pane in the store's workspace session.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: no repository rows; the fixture binds the session methods main's layout reads.
const runtime = new OrcaRuntimeService({ getRepos: () => [] } as never)
runtime.attachWindow(1)
const register = async () => {
runtime.registerPty('pty-existing', WORKTREE, null, { tabId: 'tab', leafId: LEAF })
registerBoundPty(runtime, 'pty-existing', null)
}
if (duringRefresh) {
vi.spyOn(
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { makePaneKey } from '../../shared/stable-pane-id'
import { registerBoundPty } from './bind-first-pane.test-fixture'
import {
resetAgentLaunchPanesForTests,
trackRunningAgentLaunchPane
@@ -12,6 +14,7 @@ import {
createHarness,
createPtyBackedPublishedSurfaceHarness,
createStaleTabCloseHarness,
expectDesktopDropsPane,
INCARNATION_ID,
LEAF_ID,
OTHER_WORKTREE_ID,
@@ -26,6 +29,56 @@ import {
type CloseContinuityHarness
} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures'
/**
* A CLI close of a tab's last pane. Switch-off the renderer's tab close retires the tab, then
* acknowledges; switch-on it is main's layout close, durable before any stop goes out.
*/
async function closeLastPane(
harness: CloseContinuityHarness,
handle: string
): Promise<Awaited<ReturnType<CloseContinuityHarness['runtime']['closeTerminal']>>> {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(harness.stopAndWait).not.toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalled()
harness.retirePersistedTab()
harness.acknowledged.resolve()
return closing
}
let tabsAtStop: unknown = 'not stopped'
harness.setStopAndWaitAction(() => {
tabsAtStop = harness.getSession().tabsByWorktree[WORKTREE_ID]
})
const close = await harness.runtime.closeTerminal(handle)
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
if (harness.stopAndWait.mock.calls.length > 0) {
expect(tabsAtStop).toEqual([])
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
harness.stopAndWait.mock.invocationCallOrder[0]!
)
}
return close
}
/** A refusal of a close the user may not make. Switch-off the renderer's pin guard; switch-on main's. */
function refuseTabClose(harness: CloseContinuityHarness): void {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
harness.rejectTerminalTabClose(new Error('terminal_tab_pinned'))
return
}
harness.editSession((session) => ({
...session,
tabsByWorktree: {
[WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => ({
...tab,
isPinned: true
}))
}
}))
}
describe('terminal close and handle incarnation continuity', () => {
it('delegates a stale spawn-time tab through its current PTY-backed renderer surface', async () => {
const harness = await createStaleTabCloseHarness()
@@ -37,31 +90,41 @@ describe('terminal close and handle incarnation continuity', () => {
closeMode: 'tab'
})
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Main's layout close of the tab that holds the terminal now; the window follows it.
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
} else {
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID)
}
})
it('kills and removes a stale spawn-time tab through its current headless surface', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const { terminal } = harness
const published = vi.fn()
const unsubscribe = harness.runtime.onMobileSessionTabsChanged(published)
// L259a: switch-on bug waiting on its ledger row's fix.
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it.fails : it)(
'kills and removes a stale spawn-time tab through its current headless surface',
async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const { terminal } = harness
const published = vi.fn()
const unsubscribe = harness.runtime.onMobileSessionTabsChanged(published)
await expect(harness.runtime.closeTerminalTab(terminal.handle)).resolves.toMatchObject({
handle: terminal.handle,
tabId: TAB_ID,
closeMode: 'tab'
})
await expect(harness.runtime.closeTerminalTab(terminal.handle)).resolves.toMatchObject({
handle: terminal.handle,
tabId: TAB_ID,
closeMode: 'tab'
})
expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
harness.kill.mock.invocationCallOrder[0]!
)
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
published.mock.invocationCallOrder[0]!
)
await expect(harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).resolves.toMatchObject(
{
expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
harness.kill.mock.invocationCallOrder[0]!
)
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
published.mock.invocationCallOrder[0]!
)
await expect(
harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
).resolves.toMatchObject({
retiredTerminalSurfaces: [
{
parentTabId: TAB_ID,
@@ -72,71 +135,87 @@ describe('terminal close and handle incarnation continuity', () => {
}
],
tabs: []
}
)
unsubscribe()
})
})
unsubscribe()
}
)
it('still kills and keeps the removal when the durable headless close fails to flush', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
harness.rejectPersistenceFlush(new Error('disk-full'))
// L259a: switch-on bug waiting on its ledger row's fix.
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it.fails : it)(
'still kills and keeps the removal when the durable headless close fails to flush',
async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
// Switch-on main's layout close stands and is saved again later; switch-off the runtime's does.
const errorSpy = vi
.spyOn(console, ORCA_LAYOUT_OWNER_IS_MAIN ? 'warn' : 'error')
.mockImplementation(() => undefined)
harness.rejectPersistenceFlush(new Error('disk-full'))
await expect(harness.runtime.closeTerminalTab(harness.terminal.handle)).resolves.toMatchObject({
tabId: TAB_ID,
closeMode: 'tab'
})
await expect(
harness.runtime.closeTerminalTab(harness.terminal.handle)
).resolves.toMatchObject({
tabId: TAB_ID,
closeMode: 'tab'
})
// Why: a failed flush is bookkeeping; the user's close still stops its process and the
// in-memory removal stays for the next flush to write.
expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs).toEqual([])
expect(errorSpy).toHaveBeenCalledWith(
'[runtime] failed to persist terminal close:',
expect.any(Error)
)
errorSpy.mockRestore()
})
it('publishes each split leaf retirement with its own terminal handle', async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
harness.syncSplitFixtureGraph()
const before = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
const terminalsByLeafId = new Map(
before.tabs.flatMap((tab) =>
tab.type === 'terminal' && tab.terminal ? [[tab.leafId, tab.terminal] as const] : []
// Why: a failed flush is bookkeeping; the user's close still stops its process and the
// in-memory removal stays for the next flush to write.
expect(harness.kill).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs).toEqual([])
expect(errorSpy).toHaveBeenCalledWith(
ORCA_LAYOUT_OWNER_IS_MAIN
? '[layout] a terminal close is applied but not yet saved; saving again later:'
: '[runtime] failed to persist terminal close:',
expect.any(Error)
)
)
expect(terminalsByLeafId.size).toBe(2)
harness.syncEmptyGraph()
errorSpy.mockRestore()
}
)
await expect(
harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_ID, { reason: 'user' })
).resolves.toMatchObject({ closed: true })
// L255: switch-on bug waiting on its ledger row's fix.
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it.fails : it)(
'publishes each split leaf retirement with its own terminal handle',
async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
harness.syncSplitFixtureGraph()
const before = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
const terminalsByLeafId = new Map(
before.tabs.flatMap((tab) =>
tab.type === 'terminal' && tab.terminal ? [[tab.leafId, tab.terminal] as const] : []
)
)
expect(terminalsByLeafId.size).toBe(2)
harness.syncEmptyGraph()
const after = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(after.tabs).toEqual([])
expect(after.retiredTerminalSurfaces).toEqual(
expect.arrayContaining([
expect.objectContaining({
leafId: LEAF_ID,
ptyId: PTY_ID,
terminal: terminalsByLeafId.get(LEAF_ID),
incarnationId: INCARNATION_ID
}),
expect.objectContaining({
leafId: SIBLING_LEAF_ID,
ptyId: SIBLING_PTY_ID,
terminal: terminalsByLeafId.get(SIBLING_LEAF_ID),
incarnationId: SIBLING_INCARNATION_ID
})
])
)
})
await expect(
harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_ID, { reason: 'user' })
).resolves.toMatchObject({ closed: true })
it.each(['pane', 'tab'] as const)(
const after = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(after.tabs).toEqual([])
expect(after.retiredTerminalSurfaces).toEqual(
expect.arrayContaining([
expect.objectContaining({
leafId: LEAF_ID,
ptyId: PTY_ID,
terminal: terminalsByLeafId.get(LEAF_ID),
incarnationId: INCARNATION_ID
}),
expect.objectContaining({
leafId: SIBLING_LEAF_ID,
ptyId: SIBLING_PTY_ID,
terminal: terminalsByLeafId.get(SIBLING_LEAF_ID),
incarnationId: SIBLING_INCARNATION_ID
})
])
)
}
)
// L259b: switch-on bug waiting on its ledger row's fix.
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it.fails : it).each(['pane', 'tab'] as const)(
'closes an exact hot-state %s whose failed reveal left no persisted row',
async (closeMode) => {
const harness = await createStaleTabCloseHarness({ headless: true })
@@ -172,11 +251,25 @@ describe('terminal close and handle incarnation continuity', () => {
it('does not let a PTY handle cross its recorded worktree boundary', async () => {
const harness = await createStaleTabCloseHarness({ headless: true })
const { terminal } = harness
harness.runtime.registerPty(RUNTIME_OWNED_PTY_ID, OTHER_WORKTREE_ID, null, {
tabId: STALE_TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
})
const register = () =>
ORCA_LAYOUT_OWNER_IS_MAIN
? // The crossing commit binds a new pane in the other workspace first, as every registration
// does; main's workspace fence (terminalHoldsPane, P2) refuses it the pane (L256).
registerBoundPty(harness.runtime, RUNTIME_OWNED_PTY_ID, OTHER_WORKTREE_ID, null, {
tabId: 'tab-close-continuity-crossing',
leafId: '44444444-4444-4444-8444-444444444444',
incarnationId: INCARNATION_ID
})
: harness.runtime.registerPty(RUNTIME_OWNED_PTY_ID, OTHER_WORKTREE_ID, null, {
tabId: STALE_TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(register).toThrow('pane_unbound_at_registration')
} else {
register()
}
await expect(harness.runtime.closeTerminalTab(terminal.handle)).rejects.toThrow(
'terminal_handle_stale'
@@ -190,22 +283,34 @@ describe('terminal close and handle incarnation continuity', () => {
it('does not acknowledge final-pane close before durable tab retirement', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
let settled = false
const closing = harness.runtime.closeTerminal(handle).finally(() => {
settled = true
})
await vi.waitFor(() =>
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID, {
localPtyTeardownOwnedExternally: true
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Main's layout close answers once its write is durable, and stops the terminal after it.
await expect(closeLastPane(harness, handle)).resolves.toMatchObject({
handle,
tabId: TAB_ID,
ptyKilled: false
})
expect(harness.flushOrThrow.mock.invocationCallOrder[0]).toBeLessThan(
harness.kill.mock.invocationCallOrder[0]!
)
} else {
let settled = false
const closing = harness.runtime.closeTerminal(handle).finally(() => {
settled = true
})
)
expect(settled).toBe(false)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
await vi.waitFor(() =>
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID, {
localPtyTeardownOwnedExternally: true
})
)
expect(settled).toBe(false)
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
}
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
expect(harness.closeTerminal).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
@@ -228,6 +333,15 @@ describe('terminal close and handle incarnation continuity', () => {
})
harness.setVerifiedStopResult(true)
harness.setStopAndWaitAction((stoppingPtyId) => {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Main's layout close: the tab is out of the session, and main's panes, before the stop.
if (!harness.getSession().tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === TAB_ID)) {
order.push('session-retirement')
}
if (harness.runtime['paneOfPty']({ ptyId: stoppingPtyId }).tabId === null) {
order.push('committed-graph-removal')
}
}
order.push('pty-stop')
harness.removeVictimFromInventory()
harness.syncCanaryGraph()
@@ -257,11 +371,16 @@ describe('terminal close and handle incarnation continuity', () => {
deadlineMs: expect.any(Number)
})
expect(harness.kill).not.toHaveBeenCalled()
expect(closeMobileSessionTab).toHaveBeenCalledTimes(1)
expect(closeMobileSessionTab).toHaveBeenCalledWith(`id:${WORKTREE_ID}`, TAB_ID, {
localPtyTeardownOwnedExternally: true
})
expect(harness.closeTerminalTab).toHaveBeenCalledTimes(1)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(closeMobileSessionTab).not.toHaveBeenCalled()
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
} else {
expect(closeMobileSessionTab).toHaveBeenCalledTimes(1)
expect(closeMobileSessionTab).toHaveBeenCalledWith(`id:${WORKTREE_ID}`, TAB_ID, {
localPtyTeardownOwnedExternally: true
})
expect(harness.closeTerminalTab).toHaveBeenCalledTimes(1)
}
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([
expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID })
])
@@ -287,7 +406,7 @@ describe('terminal close and handle incarnation continuity', () => {
it('does not kill the final PTY when durable tab retirement is rejected', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.rejectTerminalTabClose(new Error('terminal_tab_pinned'))
refuseTabClose(harness)
await expect(harness.runtime.closeTerminal(handle)).rejects.toThrow('terminal_tab_pinned')
@@ -295,36 +414,36 @@ describe('terminal close and handle incarnation continuity', () => {
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('requests a stop for every live tab PTY after retirement when the renderer graph is stale', async () => {
const harness = createHarness()
harness.syncSplitFixtureGraph()
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
(candidate) => candidate.ptyId === PTY_ID
)!
harness.syncFixtureGraph()
// Switch-on main's layout, not the window's graph, says what a close ends; terminal-surface-replies-
// main.test.ts "close a tab by a pane handle in main, with no session row from the window" covers it.
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
'requests a stop for every live tab PTY after retirement when the renderer graph is stale',
async () => {
const harness = createHarness()
harness.syncSplitFixtureGraph()
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
(candidate) => candidate.ptyId === PTY_ID
)!
harness.syncFixtureGraph()
const closing = harness.runtime.closeTerminal(terminal.handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(harness.kill).not.toHaveBeenCalled()
const closing = harness.runtime.closeTerminal(terminal.handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(harness.kill).not.toHaveBeenCalled()
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ ptyKilled: false })
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
expect(harness.kill).toHaveBeenCalledWith(SIBLING_PTY_ID)
})
harness.retirePersistedTab()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ ptyKilled: false })
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
expect(harness.kill).toHaveBeenCalledWith(SIBLING_PTY_ID)
}
)
it('uses verified teardown after retirement before falling back to kill', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.setVerifiedStopResult(true)
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(harness.stopAndWait).not.toHaveBeenCalled()
harness.retirePersistedTab()
harness.acknowledged.resolve()
const closing = closeLastPane(harness, handle)
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
deadlineMs: expect.any(Number)
@@ -341,10 +460,7 @@ describe('terminal close and handle incarnation continuity', () => {
harness.setVerifiedStopResult(false)
harness.runtime.markPtyLivenessUnverifiable(PTY_ID, 'its SSH provider is no longer registered')
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
const closing = closeLastPane(harness, handle)
await expect(closing).resolves.toMatchObject({
ptyKilled: false,
@@ -360,10 +476,7 @@ describe('terminal close and handle incarnation continuity', () => {
harness.setVerifiedStopResult(false)
harness.runtime.markPtyLivenessLive(PTY_ID)
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
const closing = closeLastPane(harness, handle)
await expect(closing).resolves.toMatchObject({
ptyKilled: false,
@@ -378,10 +491,7 @@ describe('terminal close and handle incarnation continuity', () => {
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.setVerifiedStopResult(true)
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
const closing = closeLastPane(harness, handle)
const close = await closing
expect(close.ptyKilled).toBe(true)
@@ -394,10 +504,7 @@ describe('terminal close and handle incarnation continuity', () => {
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.setVerifiedStopResult(new Error('provider_unavailable'))
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
harness.retirePersistedTab()
harness.acknowledged.resolve()
const closing = closeLastPane(harness, handle)
await expect(closing).resolves.toMatchObject({
ptyKilled: false,
@@ -417,17 +524,29 @@ describe('terminal close and handle incarnation continuity', () => {
expect(terminal).toMatchObject({ ptyId: RUNTIME_OWNED_PTY_ID })
const { handle } = terminal
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(closeMobileSessionTab).toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalled()
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Main's layout close commits, then the store goes away before the stop finishes.
harness.setStopAndWaitAction(() => harness.makeSessionUnavailable())
await expect(harness.runtime.closeTerminal(handle)).resolves.toMatchObject({
handle,
tabId: TAB_ID,
ptyKilled: false
})
expect(closeMobileSessionTab).not.toHaveBeenCalled()
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
} else {
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(closeMobileSessionTab).toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalled()
harness.retirePersistedTab()
harness.makeSessionUnavailable()
harness.acknowledged.resolve()
harness.retirePersistedTab()
harness.makeSessionUnavailable()
harness.acknowledged.resolve()
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
expect(harness.closeTerminal).toHaveBeenCalledWith(TAB_ID)
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
expect(harness.closeTerminal).toHaveBeenCalledWith(TAB_ID)
}
expect(harness.stopAndWait).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID, {
deadlineMs: expect.any(Number)
})
@@ -435,7 +554,7 @@ describe('terminal close and handle incarnation continuity', () => {
it('does not tear down a published PTY when retirement fails before acknowledgement', async () => {
const harness = createPtyBackedPublishedSurfaceHarness()
harness.rejectTerminalTabClose(new Error('terminal_tab_pinned'))
refuseTabClose(harness)
const [terminal] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
await expect(harness.runtime.closeTerminal(terminal.handle)).rejects.toThrow(
@@ -515,7 +634,9 @@ describe('terminal close and handle incarnation continuity', () => {
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
harness.runtime.markRendererReloading(1)
// A same-id respawn's commit stamps the pane with the incarnation it now runs.
harness.replaceIncarnation('33333333-3333-4333-8333-333333333333')
harness.replacePersistedIncarnation('33333333-3333-4333-8333-333333333333')
harness.syncFixtureGraph()
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
@@ -623,7 +744,7 @@ describe("a pane close main starts (a phone's, the CLI's)", () => {
await harness.runtime.closeTerminal(terminal.handle)
expect(harness.closeTerminalPane).toHaveBeenCalledWith(TAB_ID, LEAF_ID)
expectDesktopDropsPane(harness, TAB_ID, LEAF_ID)
expect(closed.closedByUser()).toBe(true)
expect(sibling.closedByUser()).toBe(false)
})
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import {
LEAF_ID,
PTY_ID,
@@ -7,6 +8,7 @@ import {
TAB_ID,
WORKTREE_ID,
createHarness,
expectDesktopDropsPane,
type CloseContinuityHarness
} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures'
import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement'
@@ -56,6 +58,13 @@ function syncRendererSplit(
paneRuntimeId: 7 + index,
ptyId: livePtyIdsByLeafId[leafId] ?? null
}))
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Switch-on a split is main's layout command, so main's layout holds the new pane too.
harness.editSession((session) => ({
...session,
terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, [TAB_ID]: parentLayout }
}))
}
harness.runtime.syncWindowGraph(1, {
tabs: [
{
@@ -274,39 +283,64 @@ const rows: [Entry, Condition][] = [
['phone-headless', 'no-saved-layout']
]
// L255: switch-on, a phone's pane close on a host with no window still widens; the close PR fixes it.
const awaitsL255 = (entry: string) => ORCA_LAYOUT_OWNER_IS_MAIN && entry === 'phone-headless'
describe('an explicit close of one pane never widens into its tab', () => {
it.each(rows)('%s close, %s', async (entry, condition) => {
const harness = arrange(entry, condition)
const siblingWasPersisted = Boolean(
harness.getSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[SIBLING_LEAF_ID]
)
await closePane(entry, harness)
const session = harness.getSession()
expect(session.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })])
if (siblingWasPersisted) {
expect(session.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({
type: 'leaf',
leafId: SIBLING_LEAF_ID
})
}
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.closeTerminal).not.toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalledWith(SIBLING_PTY_ID)
if (entry === 'renderer') {
return
}
if (entry === 'phone-headless') {
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs.map((tab) => tab.id)).toEqual([`${TAB_ID}::${SIBLING_LEAF_ID}`])
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
return
}
expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID)
})
it.each(rows.filter(([entry]) => !awaitsL255(entry)))('%s close, %s', closesOnlyThatPane)
it.fails.each(rows.filter(([entry]) => awaitsL255(entry)))('%s close, %s', closesOnlyThatPane)
})
async function closesOnlyThatPane(
entry: (typeof rows)[number][0],
condition: (typeof rows)[number][1]
): Promise<void> {
const harness = arrange(entry, condition)
const siblingWasPersisted = Boolean(
harness.getSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[SIBLING_LEAF_ID]
)
await closePane(entry, harness)
const session = harness.getSession()
expect(session.tabsByWorktree[WORKTREE_ID]).toEqual([expect.objectContaining({ id: TAB_ID })])
if (siblingWasPersisted) {
expect(session.terminalLayoutsByTabId[TAB_ID]?.root).toEqual({
type: 'leaf',
leafId: SIBLING_LEAF_ID
})
}
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.closeTerminal).not.toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalledWith(SIBLING_PTY_ID)
if (entry === 'renderer') {
return
}
if (entry === 'phone-headless') {
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs.map((tab) => tab.id)).toEqual([`${TAB_ID}::${SIBLING_LEAF_ID}`])
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
return
}
// A phone's close through main is main's layout close too (#31571), as the CLI's is.
expectDesktopDropsPane(harness, TAB_ID, LEAF_ID)
}
/**
* A CLI close of a tab's last pane closes the tab. Switch-off through the renderer's tab close;
* switch-on it is main's layout close, which the window follows, so no window-era notice goes out.
*/
function expectDesktopClosesTab(harness: CloseContinuityHarness): void {
expect(harness.closeTerminalPane).not.toHaveBeenCalled()
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(harness.closeTerminalTab.mock.calls.map((call) => call[0])).toEqual([TAB_ID])
return
}
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]).toBeUndefined()
}
describe("a close of a tab's last pane still closes the tab", () => {
it('on a host with no desktop window, closes the whole tab in main and for paired clients', async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
@@ -322,7 +356,9 @@ describe("a close of a tab's last pane still closes the tab", () => {
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
})
it.each([
// Switch-on a terminal's pane is bound before it registers, so a live tab with no saved pane
// cannot arise; "closes the whole tab in main and for paired clients" covers that build.
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN).each([
['no saved layout', () => ({})],
['a layout saved before its pane mounted', () => ({ [TAB_ID]: emptyLayout })]
] as const)(
@@ -350,8 +386,7 @@ describe("a close of a tab's last pane still closes the tab", () => {
await closePane('cli-pty', harness)
expect(harness.closeTerminalTab.mock.calls.map((call) => call[0])).toEqual([TAB_ID])
expect(harness.closeTerminalPane).not.toHaveBeenCalled()
expectDesktopClosesTab(harness)
})
it.each(['phone-desktop', 'cli-graph'] as const)(
@@ -366,8 +401,7 @@ describe("a close of a tab's last pane still closes the tab", () => {
await closePane(entry, harness)
expect(harness.closeTerminalTab.mock.calls.map((call) => call[0])).toEqual([TAB_ID])
expect(harness.closeTerminalPane).not.toHaveBeenCalled()
expectDesktopClosesTab(harness)
}
)
})
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import type { PtyProcessInfo } from '../providers/pty-provider-contract'
import { setupLayoutOwnerRuntime, WORKTREE_ID } from './orca-runtime-layout-owner-start-fixture'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
@@ -11,7 +12,8 @@ function pairedDesktopCreate() {
const fixture = setupLayoutOwnerRuntime()
const live: PtyProcessInfo[] = []
fixture.runtime.setPtyController({
spawn: fixture.spawn,
// As the PTY host's spawn commit: the pane is bound before the terminal registers.
spawn: committingSpawn(fixture.runtime, fixture.spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { committingSpawn } from './bind-first-pane.test-fixture'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -120,43 +121,49 @@ describe('OrcaRuntimeService terminal startup cwd', () => {
{ name: 'the persisted startup cwd', fallback: false, savedCwd: '/repo/app/packages/web' },
{ name: 'the workspace root once that folder is gone', fallback: true, savedCwd: undefined }
])('materializes restored headless mobile tabs in $name', async (c) => {
let session: unknown = {
activeRepoId: null,
activeWorktreeId: 'wt-1',
activeTabId: 'tab-1',
tabsByWorktree: {
'wt-1': [
{
id: 'tab-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'Terminal 1',
defaultTitle: 'Terminal 1',
startupCwd: '/repo/app/packages/web',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
},
terminalLayoutsByTabId: {}
}
const store = {
// wt-1 is a worktree id, not a registered repo, so getRepo returns undefined.
getRepo: () => undefined,
getWorkspaceSession: () => ({
activeRepoId: null,
activeWorktreeId: 'wt-1',
activeTabId: 'tab-1',
tabsByWorktree: {
'wt-1': [
{
id: 'tab-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'Terminal 1',
defaultTitle: 'Terminal 1',
startupCwd: '/repo/app/packages/web',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
},
terminalLayoutsByTabId: {}
})
getWorkspaceSession: () => session,
setWorkspaceSession: (next: unknown) => {
session = next
}
}
const runtime = new OrcaRuntimeService(store as never)
stubLaunchScope(runtime)
const spawn = vi.fn().mockResolvedValue({
id: 'pty-1',
...(c.fallback
? {
startupCwdFallback: { kind: 'worktree' as const, cwd: '/repo/app' },
savedStartupCwd: '/repo/app'
}
: {})
})
const spawn = vi.fn(
committingSpawn(runtime, async () => ({
id: 'pty-1',
...(c.fallback
? {
startupCwdFallback: { kind: 'worktree' as const, cwd: '/repo/app' },
savedStartupCwd: '/repo/app'
}
: {})
}))
)
runtime.setPtyController({
spawn,
write: () => true,
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { makePaneKey } from '../../shared/stable-pane-id'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { registerBoundPty } from './bind-first-pane.test-fixture'
const PTY_ID = 'ssh:target@@relay-pty'
const WORKTREE_ID = 'repo::/worktree'
@@ -8,9 +10,15 @@ const TAB_ID = 'tab-terminal'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
/** A runtime whose pty controller captures every write for assertion. */
function makeRuntime(): { runtime: OrcaRuntimeService; writes: string[] } {
function makeRuntime(opts: { paneLayout?: boolean } = {}): {
runtime: OrcaRuntimeService
writes: string[]
} {
const writes: string[] = []
const runtime = new OrcaRuntimeService(null)
// Switch-on, main's layout answers which pane holds a terminal; that layout lives in a store.
const store = opts.paneLayout && ORCA_LAYOUT_OWNER_IS_MAIN ? {} : null
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolve path reads only the session bindPaneFirst adds to this store.
const runtime = new OrcaRuntimeService(store as never)
runtime.setPtyController({
write: (_ptyId, data) => {
writes.push(data)
@@ -345,7 +353,7 @@ describe('resolveTerminalHandleByProcessIncarnation direct fencing', () => {
})
it('keeps scanning past a colon-ambiguous decoy in another host scope to the genuine match', () => {
const { runtime } = makeRuntime()
const { runtime } = makeRuntime({ paneLayout: true })
// One process-incarnation string, two colon-ambiguous pty ids that both satisfy the exact
// matcher: 'relay' + 'conn:incarnation-1' AND 'relay:conn' + 'incarnation-1' both stringify to
// 'relay:conn:incarnation-1'. The decoy is registered FIRST and lives in a different host scope
@@ -355,12 +363,12 @@ describe('resolveTerminalHandleByProcessIncarnation direct fencing', () => {
const DECOY_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const GENUINE_TAB_ID = 'tab-genuine'
const GENUINE_LEAF_ID = '33333333-3333-4333-8333-333333333333'
runtime.registerPty('relay', WORKTREE_ID, 'decoy', {
registerBoundPty(runtime, 'relay', WORKTREE_ID, 'decoy', {
tabId: DECOY_TAB_ID,
leafId: DECOY_LEAF_ID,
incarnationId: 'conn:incarnation-1'
})
runtime.registerPty('relay:conn', WORKTREE_ID, null, {
registerBoundPty(runtime, 'relay:conn', WORKTREE_ID, null, {
tabId: GENUINE_TAB_ID,
leafId: GENUINE_LEAF_ID,
incarnationId: 'incarnation-1'
@@ -12,6 +12,8 @@ import {
makeWorkspaceSessionWithHeadlessTerminal
} from './orca-runtime-test-fixtures.spec'
import type { RuntimeNotifier } from './runtime-notifier-contract'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { bindPaneFirst, committingSpawn } from './bind-first-pane.test-fixture'
describe('terminal rename before renderer graph hydration', () => {
it.each(['Media Engine Orch', null])(
@@ -25,7 +27,7 @@ describe('terminal rename before renderer graph hydration', () => {
const runtime = new OrcaRuntimeService(checkedStore)
const renameTerminal = vi.fn()
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'omp-initial-pty' })),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'omp-initial-pty' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -52,10 +54,14 @@ describe('terminal rename before renderer graph hydration', () => {
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title)
expect(renameTerminal).toHaveBeenCalledWith('host-tab', title)
runtime.onPtyExit('omp-initial-pty', 0)
// Switch-on, main closes a tab whose last terminal exits, so there the PTY is replaced by a
// restart: a new runtime over the same saved session.
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
runtime.onPtyExit('omp-initial-pty', 0)
}
const restored = new OrcaRuntimeService(checkedStore)
restored.setPtyController({
spawn: vi.fn(async () => ({ id: 'omp-replacement-pty' })),
spawn: vi.fn(committingSpawn(restored, async () => ({ id: 'omp-replacement-pty' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -75,7 +81,7 @@ describe('terminal rename before renderer graph hydration', () => {
const notifier = createMobileCreateTestNotifier(vi.fn())
runtime.setNotifier(notifier)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'surviving-pty' })),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'surviving-pty' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -209,7 +215,15 @@ describe('terminal view rename characterization', () => {
}
}
it('keeps rename persistence before the current notifier with its own receiver', async () => {
const runtime = new OrcaRuntimeService()
// Switch-on, main's layout says which tab holds the terminal; that layout lives in a store.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rename path reads only the session bindPaneFirst adds to this store.
const runtime = new OrcaRuntimeService(ORCA_LAYOUT_OWNER_IS_MAIN ? ({} as never) : undefined)
bindPaneFirst(runtime, {
worktreeId: 'folder-workspace',
tabId: 'host-tab',
leafId: '00000000-0000-4000-8000-000000000002',
ptyId: 'rename-pty'
})
const pty = runtime['recordPtyWorktree']('rename-pty', 'folder-workspace', {
tabId: 'host-tab'
})
@@ -246,7 +260,9 @@ describe('terminal view rename characterization', () => {
})
describe('terminal query erased boundary regressions', () => {
it('keeps the cached-null pane failure when a later binding read is valid', () => {
// paneOfPty reads the record's pane once, so a value that changes between reads cannot split one
// summary (L80); a plain record never does that.
it('summarizes from one read of the pane, though a later read would differ', () => {
class SummaryRuntime extends OrcaRuntimeService {
summarize() {
const pty = this.recordPtyWorktree('changing-binding', 'missing', { connected: false })
@@ -258,8 +274,11 @@ describe('terminal query erased boundary regressions', () => {
})
const issueHandle = vi.fn(() => 'handle')
Object.defineProperty(this, 'issuePtyHandle', { value: issueHandle })
expect(() => this.buildPtyTerminalSummary(pty, new Map())).toThrow(TypeError)
expect(reads).toBeGreaterThanOrEqual(2)
expect(this.buildPtyTerminalSummary(pty, new Map())).toMatchObject({
orphaned: true,
tabId: 'pty:changing-binding'
})
expect(reads).toBe(ORCA_LAYOUT_OWNER_IS_MAIN ? 0 : 1)
expect(issueHandle).toHaveBeenCalledExactlyOnceWith(pty)
}
}
@@ -8,6 +8,7 @@ import { makePaneKey } from '../../shared/stable-pane-id'
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
import { OrcaRuntimeService } from './orca-runtime'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { bindPaneFirst, committingSpawn } from './bind-first-pane.test-fixture'
const REPO_ID = 'repo-1'
const WORKTREE_ID = `${REPO_ID}::/workspace`
@@ -16,6 +17,14 @@ const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SOURCE_PTY_ID = 'pty-source'
const SPLIT_PTY_ID = 'pty-split'
const SPLIT_INCARNATION_ID = 'pty-split-inc'
// Switch-on, main stamps the source's pane with its terminal's incarnation, so they start equal.
const SOURCE_STAMPED_BEFORE = ORCA_LAYOUT_OWNER_IS_MAIN
? { sourceIncarnationId: 'persisted-before' }
: {}
// Switch-on, a source main's layout no longer holds has no pane, so its handle is stale first.
const UNOWNED_SOURCE_ERROR = ORCA_LAYOUT_OWNER_IS_MAIN
? 'terminal_handle_stale'
: 'terminal_split_source_not_found'
function sourceLayout(): TerminalLayoutSnapshot {
return {
@@ -92,6 +101,8 @@ function createHarness(
const connectionId = options.connectionId ?? null
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
const requestedSessionHostIds: (string | undefined)[] = []
// The fixture's own spawn-commit reads are not the split's.
let committingPane = false
const repo = {
id: REPO_ID,
path: '/workspace',
@@ -104,7 +115,9 @@ function createHarness(
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getWorkspaceSession: (hostId?: string) => {
requestedSessionHostIds.push(hostId)
if (!committingPane) {
requestedSessionHostIds.push(hostId)
}
return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession()
},
setWorkspaceSession: (next: WorkspaceSessionState) => {
@@ -113,14 +126,6 @@ function createHarness(
persistPtyBinding: () => true
})
let resolveSpawn: ((result: { id: string; incarnationId: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
() =>
new Promise<{ id: string; incarnationId?: string }>((resolve) => {
resolveSpawn = resolve
})
)
: vi.fn(async () => ({ id: SPLIT_PTY_ID }))
const kill = vi.fn(() => true)
const retireRejectedPty = vi.fn()
const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true)
@@ -135,6 +140,25 @@ function createHarness(
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const rendererSplitTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store as never)
// As the PTY host's spawn commit: the split's pane is bound once its terminal starts.
const spawn = options.deferSpawn
? vi.fn(
committingSpawn(
runtime,
(_args: { worktreeId?: string; tabId?: string; leafId?: string }) =>
new Promise<{ id: string; incarnationId?: string }>((resolve) => {
resolveSpawn = resolve
})
)
)
: vi.fn(
committingSpawn(
runtime,
async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => ({
id: SPLIT_PTY_ID
})
)
)
Object.assign(runtime, {
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: WORKTREE_ID,
@@ -180,13 +204,32 @@ function createHarness(
: [],
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
})
if (!options.graphOnlySource) {
// As the PTY host's spawn commit: the source's pane is bound in its owner's partition first.
const registerSource = (incarnationId?: string) => {
committingPane = true
try {
bindPaneFirst(
runtime,
{ worktreeId: WORKTREE_ID, tabId: TAB_ID, leafId: SOURCE_LEAF_ID, ptyId: SOURCE_PTY_ID },
ownerHostId
)
} finally {
committingPane = false
}
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
...(incarnationId ? { incarnationId } : {})
})
}
if (!options.graphOnlySource) {
registerSource(options.sourceIncarnationId)
if (!includeSource && ORCA_LAYOUT_OWNER_IS_MAIN) {
// Switch-on, a registered terminal's pane was bound first, so an unowned source is one
// whose tab was closed since: the saved session no longer lists it.
session = persistedSession(false)
}
}
const internals = runtime as unknown as {
issueHandle: (leaf: unknown) => string
issuePtyHandle: (pty: unknown) => string
@@ -209,12 +252,7 @@ function createHarness(
getSession: () => session,
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
requestedSessionHostIds,
replaceSourceIncarnation: (incarnationId: string) =>
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
incarnationId
}),
replaceSourceIncarnation: (incarnationId: string) => registerSource(incarnationId),
replacePersistedSourceIncarnation: (incarnationId: string) => {
session = {
...session,
@@ -314,11 +352,16 @@ describe('remote runtime terminal split authority', () => {
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root?.type).toBe('split')
})
it('does not reveal a persisted split into a renderer bound to a different PTY', async () => {
const harness = createHarness(true, { rendererMounted: true, rendererPtyId: 'replacement' })
await harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
// Switch-off only: with the switch on, main's leaves stand in for the window's in workspaces main
// owns, so a window pane bound to another terminal cannot exist (terminal-split-source-authority.ts).
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
'does not reveal a persisted split into a renderer bound to a different PTY',
async () => {
const harness = createHarness(true, { rendererMounted: true, rendererPtyId: 'replacement' })
await harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
}
)
// STA-9417: the window's graph lags a tab it already holds, so the split is offered regardless;
// a window without the tab rejects it and the persisted split stands.
@@ -392,7 +435,7 @@ describe('remote runtime terminal split authority', () => {
.then(() => 'resolved')
.catch((error: unknown) => (error instanceof Error ? error.message : String(error)))
expect.soft(outcome).toBe('terminal_split_source_not_found')
expect.soft(outcome).toBe(UNOWNED_SOURCE_ERROR)
expect.soft(harness.spawn).not.toHaveBeenCalled()
expect.soft(harness.kill).not.toHaveBeenCalled()
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
@@ -468,7 +511,8 @@ describe('remote runtime terminal split authority', () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
stopAndWaitResult: true
stopAndWaitResult: true,
...SOURCE_STAMPED_BEFORE
})
harness.replacePersistedSourceIncarnation('persisted-before')
@@ -491,7 +535,7 @@ describe('remote runtime terminal split authority', () => {
await expect(
harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
).rejects.toThrow('terminal_split_source_not_found')
).rejects.toThrow(UNOWNED_SOURCE_ERROR)
expect(harness.spawn).not.toHaveBeenCalled()
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
@@ -500,7 +544,8 @@ describe('remote runtime terminal split authority', () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
stopAndWaitResult: false
stopAndWaitResult: false,
...SOURCE_STAMPED_BEFORE
})
harness.kill.mockImplementation(() => {
throw new Error('kill failed')
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { Store } from '../persistence/loading-store/store'
@@ -17,6 +18,7 @@ import {
WORKTREE_ID,
WORKTREE_PATH,
createHarness,
expectDesktopDropsPane,
makeSession
} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures'
import {
@@ -200,8 +202,8 @@ describe('CLI close of one pane in a split tab', () => {
root: { type: 'leaf', leafId: SIBLING_LEAF_ID },
ptyIdsByLeafId: { [SIBLING_LEAF_ID]: SIBLING_PTY_ID }
})
// No exit arrives to remove the pane, so the desktop renderer is told to drop that leaf.
expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID)
// No exit arrives to remove the pane, so the desktop is told to drop that leaf.
expectDesktopDropsPane(harness, TAB_ID, LEAF_ID)
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
})
@@ -224,7 +226,7 @@ describe('CLI close of one pane in a split tab', () => {
type: 'leaf',
leafId: SIBLING_LEAF_ID
})
expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID)
expectDesktopDropsPane(harness, TAB_ID, LEAF_ID)
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
})
@@ -256,7 +258,7 @@ describe('CLI close of one pane in a split tab', () => {
type: 'leaf',
leafId: SIBLING_LEAF_ID
})
expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID)
expectDesktopDropsPane(harness, TAB_ID, LEAF_ID)
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
// The owed stop still goes out through the controller's kill, which records SSH pending kills.
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
@@ -269,31 +271,35 @@ describe('CLI close of one pane in a split tab', () => {
}
)
it('drops only that pane from paired clients on a host with no renderer listing the tab', async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
harness.syncSplitFixtureGraph()
harness.syncFixtureTabWithoutLeaf()
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
(candidate) => candidate.ptyId === PTY_ID
)!
harness.syncEmptyGraph()
// No exit arrives to retire the pane from the published snapshot.
harness.setVerifiedStopResult(new Error('ssh_host_unreachable'))
// L255: switch-on bug waiting on the close PR.
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it.fails : it)(
'drops only that pane from paired clients on a host with no renderer listing the tab',
async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
harness.syncSplitFixtureGraph()
harness.syncFixtureTabWithoutLeaf()
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
(candidate) => candidate.ptyId === PTY_ID
)!
harness.syncEmptyGraph()
// No exit arrives to retire the pane from the published snapshot.
harness.setVerifiedStopResult(new Error('ssh_host_unreachable'))
await harness.runtime.closeTerminal(terminal.handle)
await harness.runtime.closeTerminal(terminal.handle)
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs).toEqual([
expect.objectContaining({ parentTabId: TAB_ID, leafId: SIBLING_LEAF_ID })
])
expect(snapshot.retiredTerminalSurfaces).toEqual([
expect.objectContaining({ parentTabId: TAB_ID, leafId: LEAF_ID, ptyId: PTY_ID })
])
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({
type: 'leaf',
leafId: SIBLING_LEAF_ID
})
})
const snapshot = await harness.runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(snapshot.tabs).toEqual([
expect.objectContaining({ parentTabId: TAB_ID, leafId: SIBLING_LEAF_ID })
])
expect(snapshot.retiredTerminalSurfaces).toEqual([
expect.objectContaining({ parentTabId: TAB_ID, leafId: LEAF_ID, ptyId: PTY_ID })
])
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({
type: 'leaf',
leafId: SIBLING_LEAF_ID
})
}
)
it('keeps the sibling when the stop delivers the exit before the pane commit', async () => {
const harness = createHarness()
@@ -330,7 +336,7 @@ describe('mobile close of one pane in a split tab', () => {
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]).toMatchObject({
root: { type: 'leaf', leafId: SIBLING_LEAF_ID }
})
expect(harness.closeTerminalPane).toHaveBeenCalledExactlyOnceWith(TAB_ID, LEAF_ID)
expectDesktopDropsPane(harness, TAB_ID, LEAF_ID)
})
})
@@ -339,12 +345,28 @@ describe('mobile close of a tab the desktop renderer lists', () => {
it('still asks the renderer, whose pin guard can refuse it', async () => {
const harness = createHarness({ publishMobileSurface: true, registerPtyBacked: true })
harness.rejectTerminalTabClose(new Error('terminal_tab_pinned'))
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main's layout holds the pin and refuses the close itself; the renderer is not asked.
harness.editSession((session) => ({
...session,
tabsByWorktree: {
[WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => ({
...tab,
isPinned: true
}))
}
}))
}
await expect(
harness.runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, TAB_ID, { reason: 'user' })
).rejects.toThrow('terminal_tab_pinned')
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
} else {
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID)
}
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([
expect.objectContaining({ id: TAB_ID })
@@ -1,5 +1,8 @@
import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture'
import { expect, vi } from 'vitest'
import { afterEach, beforeEach, expect, vi } from 'vitest'
import { forgetInstalledPaneSession } from './bind-first-pane.test-fixture'
import { announceSessionWrites } from './profile-session-store.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { createHash } from 'node:crypto'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { OrcaRuntimeService } from './orca-runtime'
@@ -592,6 +595,8 @@ function makeRuntimeStoreWithWorkspaceSession(
getWorkspaceSession: (hostId?: string) =>
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
setWorkspaceSession: vi.fn(setSession),
// As a profile store: the partitions it holds, which main's layout index reads.
getWorkspaceSessionHostIds: () => [ownerHostId],
// Main's `layout.apply` on this partition, as the Store applies it (switch-on writes).
applyLayoutCommand: vi.fn(
async (
@@ -688,6 +693,15 @@ function makeWorkspaceSessionWithHeadlessTerminal(
}
}
// Layout-owner tests bind panes into the shared store, which announces its writes as a profile
// Store does; each test starts without them.
beforeEach(() => {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
announceSessionWrites(store)
}
})
afterEach(() => forgetInstalledPaneSession(store))
computeWorktreePathMock.mockImplementation(
(
sanitizedName: string,
@@ -2,6 +2,7 @@ import * as mocks from './orca-runtime-test-mocks.spec'
import type { MockInstance } from 'vitest'
import * as worktreeChangeInvalidators from '../ipc/worktree-change-invalidators'
import { awaitBackgroundRemovalsInRuntimeTests } from './orca-runtime-background-removal-test-support'
import { commitFakeSpawnsInRuntimeTests } from './bind-first-pane.test-fixture'
import {
_resetPendingWorktreeRemovalsForTests,
_settlePendingWorktreeRemovalsForTests
@@ -65,6 +66,7 @@ const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prototype carries the removal method the wrapper replaces.
awaitBackgroundRemovalsInRuntimeTests(mocks.OrcaRuntimeService.prototype as never)
commitFakeSpawnsInRuntimeTests(mocks.OrcaRuntimeService.prototype)
function resetRuntimeTestMocks(): void {
_resetCanonicalRepoKeyCacheForTests()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, makePaneKey } from '../orca-runtime-test-mocks.spec'
import type { RuntimeTerminalAgentStatusEvent } from '../orca-runtime-test-mocks.spec'
import {
@@ -17,7 +18,7 @@ describe('OrcaRuntimeService', () => {
onTerminalAgentStatus: (event) => statuses.push(event)
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,5 +1,6 @@
import { settledWriteStub } from '../../providers/settled-pty-write-stub'
import { describe, expect, it, vi } from 'vitest'
import { registerLivePty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import {
InMemoryOrchestrationMessages,
@@ -138,6 +139,7 @@ describe('OrcaRuntimeService', () => {
// parsePaneKey(pty.paneKey).leafId to the republished leafId, and a non-UUID
// id falls back to `tabId:paneRuntimeId`, so it is always fenced after exit.
const leafId = '11111111-1111-1111-8111-111111111111'
registerLivePty(runtime, 'pty-1', TEST_WORKTREE_ID, null, { tabId: 'tab-1', leafId })
const syncUuidLeaf = (): void => {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
@@ -172,6 +174,8 @@ describe('OrcaRuntimeService', () => {
runtime.onPtyExit('pty-1', 0)
runtime.onPtySpawned('pty-1', undefined, { awaitsRegistration: false })
// Main's exit cleanup dropped the pane; the respawn's commit binds it again.
registerLivePty(runtime, 'pty-1', TEST_WORKTREE_ID, null, { tabId: 'tab-1', leafId })
// Drop the leaf, then republish it: the rebuilt record's tailSource is the
// PTY record rather than the previous leaf, which is what pins the clear
// onPtyExit applies at the pty level.
@@ -1,4 +1,6 @@
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { describe, expect, it, vi } from 'vitest'
import { bindPaneFirst, registerBoundPty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, electronMocks, ipcMain } from '../orca-runtime-test-mocks.spec'
import {
TEST_WORKTREE_ID,
@@ -33,7 +35,10 @@ describe('OrcaRuntimeService', () => {
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
// Why: mirrors the spawn IPC handler — a command-carrying spawn records
// its launch command right after registering the PTY.
runtime.registerPty('pty-carried', TEST_WORKTREE_ID, null, { tabId: 'tab-carried', leafId })
registerBoundPty(runtime, 'pty-carried', TEST_WORKTREE_ID, null, {
tabId: 'tab-carried',
leafId
})
runtime.noteTerminalSpawnCommand('pty-carried', 'codex')
ipcMain.emit(
'terminal:tabCreateReply',
@@ -146,7 +151,7 @@ describe('OrcaRuntimeService', () => {
).toBe(false)
// The renderer's own PTY spawn registers with the binding and rescues it.
runtime.registerPty('pty-guard', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-guard', TEST_WORKTREE_ID, null, {
tabId: 'tab-guard',
leafId
})
@@ -203,7 +208,22 @@ describe('OrcaRuntimeService', () => {
// Let the create clear its pre-wait check and park in waitForMobileTerminalSurface before any identity arrives.
await vi.advanceTimersByTimeAsync(50)
// Identity arrives via leaf graph-sync, NOT registerPty, so no rescue fires and the surface is never published.
// Switch point: a window leaf vouches for no terminal with the switch on (L80), so the shell
// that keeps the create alive is the one its spawn commit registers, as the rescue at :153.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
registerBoundPty(runtime, 'pty-catch', TEST_WORKTREE_ID, null, {
tabId: 'tab-catch',
leafId
})
await vi.advanceTimersByTimeAsync(50)
}
// Switch-off, identity arrives via leaf graph-sync, NOT registerPty, so no rescue fires and the surface is never published.
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-catch',
leafId,
ptyId: 'pty-catch'
})
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [
@@ -217,8 +237,8 @@ describe('OrcaRuntimeService', () => {
]
})
// Surface still unpublished, so the create is still pending.
expect(settled).toBe(false)
// Switch-off the surface is still unpublished, so the create is still pending.
expect(settled).toBe(ORCA_LAYOUT_OWNER_IS_MAIN)
// Cross the 10s surface timeout: the catch path must rescue from the live PTY, not roll the session back destructively.
await vi.advanceTimersByTimeAsync(11_000)
@@ -9,6 +9,7 @@ import {
makeWorkspaceSessionWithHeadlessTerminal
} from '../orca-runtime-test-fixtures.spec'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types'
import { applyCommandToSession } from '../../persistence/terminal-topology/terminal-pane-admission'
@@ -121,7 +122,8 @@ function headlessRuntimeWithChat({ asProfileStore = false } = {}) {
async function openChat(runtime: OrcaRuntimeService): Promise<void> {
await runtime.listMobileSessionTabs(WORKTREE)
runtime.projectStructuredAgentSessionTab({
// As every product path opens one: main's layout records the tab first.
await runtime.publishStructuredAgentSessionTab({
workspaceId: TEST_WORKTREE_ID,
sessionId: 'claude_29968',
agent: 'claude',
@@ -223,7 +225,8 @@ describe('a chat tab on a host with no window', () => {
leaves: [],
unchangedMobileSessionWorktrees: [TEST_WORKTREE_ID]
})
runtime.projectStructuredAgentSessionTab({
// As every product path opens one: main's layout records the tab first.
await runtime.publishStructuredAgentSessionTab({
workspaceId: TEST_WORKTREE_ID,
sessionId: 'claude_29968',
agent: 'claude',
@@ -231,15 +234,14 @@ describe('a chat tab on a host with no window', () => {
})
const listed = await runtime.listMobileSessionTabs(WORKTREE)
expect(listed.tabGroups?.flatMap((group) => group.tabOrder)).toEqual([
CHAT_TAB_ID,
'win-term',
'serve-tab'
])
expect(listed.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id))).toEqual([
CHAT_TAB_ID,
'win-term',
'serve-tab'
])
// Switch point: main's layout owns the workspace's tabs and their order; a window draws them and
// cannot add a tab or an order main lacks, so the chat takes its place in main's order.
const expectedOrder = ORCA_LAYOUT_OWNER_IS_MAIN
? ['serve-tab', 'tab-readme', CHAT_TAB_ID]
: [CHAT_TAB_ID, 'win-term', 'serve-tab']
expect(listed.tabGroups?.flatMap((group) => group.tabOrder)).toEqual(expectedOrder)
expect(listed.tabs.map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id))).toEqual(
expectedOrder
)
})
})
@@ -1,5 +1,6 @@
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn, registerBoundPty } from '../bind-first-pane.test-fixture'
import {
MOCK_GIT_WORKTREES,
OrcaRuntimeService,
@@ -18,6 +19,15 @@ import {
store
} from '../orca-runtime-test-fixtures.spec'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import {
SPLIT_LEFT,
SPLIT_RIGHT,
TRAILING,
LEFT_LEAF,
RIGHT_LEAF,
TRAILING_LEAF,
splitPlacementStore
} from './split-placement-session.test-fixture'
describe('OrcaRuntimeService', () => {
it('keeps client selection when a renderer session-tab close cannot commit', async () => {
@@ -302,31 +312,28 @@ describe('OrcaRuntimeService', () => {
])
})
it.each([
const placementCases = [
{
label: 'after the split parent for a capable client',
supportsSplitGroupPlacement: true,
expectedOrder: (createdId: string) => [
'split::left',
'split::right',
createdId,
'trailing::leaf'
]
expectedOrder: (createdId: string) => [SPLIT_LEFT, SPLIT_RIGHT, createdId, TRAILING]
},
{
label: 'at the end for an older client',
supportsSplitGroupPlacement: false,
expectedOrder: (createdId: string) => [
'split::left',
'split::right',
'trailing::leaf',
createdId
]
expectedOrder: (createdId: string) => [SPLIT_LEFT, SPLIT_RIGHT, TRAILING, createdId]
}
])('places a runtime-owned terminal $label', async (testCase) => {
const runtime = new OrcaRuntimeService(store)
]
it.each(placementCases)('places a runtime-owned terminal $label', placesRuntimeOwnedTerminal)
async function placesRuntimeOwnedTerminal(testCase: (typeof placementCases)[number]) {
// Switch point: the split and trailing tabs are main's own, saved in its layout; switch-off a
// host with no window knows them from the snapshot it published.
const runtime = new OrcaRuntimeService(
ORCA_LAYOUT_OWNER_IS_MAIN ? splitPlacementStore() : store
)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-created-after-split' }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'pty-created-after-split' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -340,30 +347,30 @@ describe('OrcaRuntimeService', () => {
publicationEpoch: 'headless:split-placement',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'split::left',
activeTabId: SPLIT_LEFT,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'split::left',
id: SPLIT_LEFT,
parentTabId: 'split',
leafId: 'left',
leafId: LEFT_LEAF,
title: 'Split',
isActive: true
},
{
type: 'terminal',
id: 'split::right',
id: SPLIT_RIGHT,
parentTabId: 'split',
leafId: 'right',
leafId: RIGHT_LEAF,
title: 'Split',
isActive: false
},
{
type: 'terminal',
id: 'trailing::leaf',
id: TRAILING,
parentTabId: 'trailing',
leafId: 'leaf',
leafId: TRAILING_LEAF,
title: 'Trailing',
isActive: false
}
@@ -373,7 +380,7 @@ describe('OrcaRuntimeService', () => {
})
const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
afterTabId: 'split::left',
afterTabId: SPLIT_LEFT,
clientNavigationId: 'mobile-client',
supportsSplitGroupPlacement: testCase.supportsSplitGroupPlacement
})
@@ -381,7 +388,7 @@ describe('OrcaRuntimeService', () => {
expect(
(await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map((tab) => tab.id)
).toEqual(testCase.expectedOrder(created.tab.id))
})
}
it('asks a headed host to append for an older client', async () => {
const runtime = new OrcaRuntimeService(store)
@@ -495,7 +502,7 @@ describe('OrcaRuntimeService', () => {
}
webContents.send.mockImplementation((_channel: string, payload: { requestId: string }) => {
expect(setBackgroundThrottling).toHaveBeenCalledWith(false)
runtime.registerPty('pty-paired-headed', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-paired-headed', TEST_WORKTREE_ID, null, {
tabId: 'tab-paired-headed',
leafId
})
@@ -763,7 +770,7 @@ describe('OrcaRuntimeService', () => {
let spawnIndex = 0
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: `pty-headless-${++spawnIndex}` })),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: `pty-headless-${++spawnIndex}` }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -791,7 +798,7 @@ describe('OrcaRuntimeService', () => {
let spawnIndex = 0
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: `pty-headless-${++spawnIndex}` })),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: `pty-headless-${++spawnIndex}` }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,5 +1,6 @@
import { withDurableRuntimeStore } from '../runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import {
OrcaRuntimeService,
electronMocks,
@@ -41,7 +42,7 @@ describe('OrcaRuntimeService', () => {
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -81,7 +82,8 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'ssh:ssh-1@@remote-pty' })
const runtime = new OrcaRuntimeService(remoteStore as never)
runtime.setPtyController({
spawn,
// The real Store keeps an SSH repo's session in its host's partition.
spawn: committingSpawn(runtime, spawn, 'ssh:ssh-1'),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -264,7 +266,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -560,7 +562,7 @@ describe('OrcaRuntimeService', () => {
.mockResolvedValueOnce({ id: 'laptop-split-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -600,7 +602,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -639,7 +641,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'claude'
@@ -1,5 +1,8 @@
import { withDurableRuntimeStore } from '../runtime-durable-store-fixture'
import { durableSessionLayoutApply } from '../__fixtures__/main-layout-close-fixture'
import { describe, expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { committingSpawn, registerLivePty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec'
import type { RuntimeMobileSessionTabsResult } from '../orca-runtime-test-mocks.spec'
import {
@@ -18,7 +21,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'claude'
@@ -92,7 +95,7 @@ describe('OrcaRuntimeService', () => {
it("publishes the desktop's tab name over the live agent title, and the live title without one", async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'renamed-pty' }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'renamed-pty' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'claude'
@@ -137,6 +140,19 @@ describe('OrcaRuntimeService', () => {
}
]
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Switch point: the desktop's tab name is main's own layout, renamed by main's command
// (#31571), not the window's published row; generated names: orca-runtime-desktop-tab-names.
const [{ handle }] = (await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).terminals
await runtime.renameTerminal(handle, 'Build fixer')
runtime.onPtyData('renamed-pty', '\x1b]0;⠂ Claude Code\x07', 124)
const named = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(named.tabs[0]).toEqual(expect.objectContaining({ title: 'Build fixer' }))
await runtime.renameTerminal(handle, null)
const unnamed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(unnamed.tabs[0]).toEqual(expect.objectContaining({ title: '⠂ Claude Code' }))
return
}
publish(1, { customTitle: 'Build fixer' })
runtime.onPtyData('renamed-pty', '\x1b]0;⠂ Claude Code\x07', 124)
@@ -157,7 +173,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -268,7 +284,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier({ closeTerminal } as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill,
getForegroundProcess: async () => null
@@ -300,7 +316,7 @@ describe('OrcaRuntimeService', () => {
})
it('waits for renderer acknowledgement before returning a whole-tab close receipt', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const acknowledged = makeDeferred()
@@ -333,8 +349,24 @@ describe('OrcaRuntimeService', () => {
}
]
})
registerLivePty(runtime, 'persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
const [terminal] = (await runtime.listTerminals()).terminals
const pending = runtime.closeTerminalTab(terminal.handle)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main closes the tab in its own layout, so there is no renderer close to await.
await expect(pending).resolves.toEqual({
handle: terminal.handle,
tabId: 'host-tab',
closeMode: 'tab',
ptyKilled: false
})
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
return
}
let settled = false
void pending.finally(() => {
settled = true
@@ -353,7 +385,7 @@ describe('OrcaRuntimeService', () => {
})
it('leases renderer publication until a paired whole-tab close is acknowledged', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const acknowledged = makeDeferred()
@@ -425,6 +457,14 @@ describe('OrcaRuntimeService', () => {
reason: 'user',
clientNavigationId: 'device-a'
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main commits the close itself, so no renderer close holds a publication lease.
await expect(pending).resolves.toEqual({ closed: true })
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(setBackgroundThrottling).not.toHaveBeenCalled()
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
return
}
await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab'))
expect(setBackgroundThrottling.mock.calls).toEqual([[false]])
@@ -509,6 +549,13 @@ describe('OrcaRuntimeService', () => {
reason: 'user',
clientNavigationId: 'device-a'
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main's own close commits before any renderer could persist a removal.
await expect(pending).resolves.toEqual({ closed: true })
expect(kill).toHaveBeenCalledWith('persisted-pty')
expect(closeTerminalTab).not.toHaveBeenCalled()
return
}
await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab'))
const session = getSession()
setSession({
@@ -553,7 +600,7 @@ describe('OrcaRuntimeService', () => {
})
it('durably closes every split leaf without a renderer', async () => {
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
@@ -580,8 +627,15 @@ describe('OrcaRuntimeService', () => {
.mockResolvedValueOnce({ id: 'headless-left' })
.mockResolvedValueOnce({ id: 'headless-right' })
const kill = vi.fn(() => true)
const durableStore = withDurableRuntimeStore({ ...runtimeStore, flushOrThrow })
// As the Store runs main's `layout.apply`: one durable write.
const runtime = new OrcaRuntimeService(
withDurableRuntimeStore({ ...runtimeStore, flushOrThrow })
Object.assign(durableStore, {
applyLayoutCommand: durableSessionLayoutApply(durableStore, {
get: getSession,
set: setSession
})
})
)
runtime.setPtyController({
spawn,
@@ -640,7 +694,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import {
OrcaRuntimeService,
@@ -496,7 +497,9 @@ describe('OrcaRuntimeService', () => {
let ptyCounter = 0
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: `target-group-pty-${++ptyCounter}` })),
spawn: vi.fn(
committingSpawn(runtime, async () => ({ id: `target-group-pty-${++ptyCounter}` }))
),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -18,6 +18,7 @@ import {
import { makePendingAgentTabActivationRuntime } from '../orca-runtime-test-scenario-builders.spec'
import { trackRunningAgentLaunchPane } from '../../agent-launch/agent-launch-pane-attachment'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
describe('OrcaRuntimeService', () => {
it('briefly preserves abnormal SSH exits for paired pane recovery', async () => {
@@ -424,14 +425,23 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({ parentTabId: 'host-tab', status: 'pending-handle' })
])
// Switch point: the lease bounds only a row the window's list omits. Main's layout holds the
// tab with the switch on, so the phone lists it as the desktop draws it, whatever the lease.
const afterLease = ORCA_LAYOUT_OWNER_IS_MAIN
? [expect.objectContaining({ parentTabId: 'host-tab', status: 'pending-handle' })]
: []
vi.advanceTimersByTime(30_001)
publishEmpty(2)
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual(
afterLease
)
leaseState = 'terminated'
leaseUpdatedAt = Date.now()
publishEmpty(3)
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual(
afterLease
)
} finally {
vi.useRealTimers()
}
@@ -1,5 +1,8 @@
import { withDurableRuntimeStore } from '../runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { durableSessionLayoutApply } from '../__fixtures__/main-layout-close-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { registerBoundPty } from '../bind-first-pane.test-fixture'
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec'
import {
@@ -85,7 +88,8 @@ describe('OrcaRuntimeService', () => {
})
it('collapses duplicate mobile terminal entries when renderer and headless leaf ids diverge for the same pty', async () => {
const rendererLeafId = HEADLESS_SECOND_LEAF_ID
// Switch point: the window draws main's own panes, so its leaf ids are main's and never diverge.
const rendererLeafId = ORCA_LAYOUT_OWNER_IS_MAIN ? HEADLESS_LEAF_ID : HEADLESS_SECOND_LEAF_ID
const ptyId = 'serve-persisted-pty'
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
@@ -165,8 +169,13 @@ describe('OrcaRuntimeService', () => {
})
it('keeps distinct split mobile terminal ptys under the same parent tab', async () => {
const rendererLeftLeafId = '33333333-3333-4333-8333-333333333333'
const rendererRightLeafId = '44444444-4444-4444-8444-444444444444'
// Switch point: the window draws main's own panes, so its leaf ids are main's and never diverge.
const rendererLeftLeafId = ORCA_LAYOUT_OWNER_IS_MAIN
? HEADLESS_LEAF_ID
: '33333333-3333-4333-8333-333333333333'
const rendererRightLeafId = ORCA_LAYOUT_OWNER_IS_MAIN
? HEADLESS_SECOND_LEAF_ID
: '44444444-4444-4444-8444-444444444444'
const leftPtyId = 'serve-left'
const rightPtyId = 'serve-right'
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
@@ -283,8 +292,13 @@ describe('OrcaRuntimeService', () => {
})
it('does not mark persisted PTY id collisions ready without matching pane identity', async () => {
// The saved pane carries the incarnation of the terminal it ran before the restart.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
makeWorkspaceSessionWithHeadlessTerminal({
terminalPtyIncarnationsByPaneKey: {
[`host-tab:${HEADLESS_LEAF_ID}`]: 'inc-before-restart'
}
})
)
let rows: AgentStatusIpcPayload[] = []
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
@@ -299,9 +313,11 @@ describe('OrcaRuntimeService', () => {
{ id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Unrelated PTY' }
]
})
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
// The colliding live terminal's own spawn commit bound other-tab, under its own incarnation.
registerBoundPty(runtime, 'persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'other-tab',
leafId: '99999999-9999-4999-8999-999999999999'
leafId: '99999999-9999-4999-8999-999999999999',
incarnationId: 'inc-now'
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const unrelatedPty = runtime['ptysById'].get('persisted-pty')!
@@ -341,10 +357,13 @@ describe('OrcaRuntimeService', () => {
id: `host-tab-${index}`,
ptyId: `missing-pty-${index}`
}))
// Each pane has its own leaf id, as every pane a layout mints does.
const terminalLayoutsByTabId = Object.fromEntries(
tabs.map((tab, index) => [
tab.id,
makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: `missing-pty-${index}` })
makeHeadlessTerminalLayout({
[`${String(index).padStart(8, '0')}-1111-4111-8111-111111111111`]: `missing-pty-${index}`
})
])
)
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
@@ -414,7 +433,7 @@ describe('OrcaRuntimeService', () => {
it('durably tears down a runtime-owned SSH headless tab when renderer cleanup fails', async () => {
// #8958: the renderer relay can't see headless tabs, so its advisory fallback must not block authoritative teardown/flush.
const persistedPtyId = 'ssh:ssh-1@@relay-pty'
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
@@ -443,8 +462,15 @@ describe('OrcaRuntimeService', () => {
})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const closeTerminalTab = vi.fn(async () => {})
const durableStore = withDurableRuntimeStore({ ...runtimeStore, flushOrThrow })
// As the Store runs main's `layout.apply`: one durable write.
const runtime = new OrcaRuntimeService(
withDurableRuntimeStore({ ...runtimeStore, flushOrThrow })
Object.assign(durableStore, {
applyLayoutCommand: durableSessionLayoutApply(durableStore, {
get: getSession,
set: setSession
})
})
)
runtime.setPtyController({
write: () => true,
@@ -459,12 +485,17 @@ describe('OrcaRuntimeService', () => {
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(kill).toHaveBeenCalledWith(persistedPtyId)
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expect(flushOrThrow).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
'[runtime] failed to notify renderer after headless terminal close',
{ parentTabId: 'host-tab', error: rendererError }
)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main closes in its own layout, so no renderer cleanup runs to fail.
expect(closeTerminal).not.toHaveBeenCalled()
} else {
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expect(warn).toHaveBeenCalledWith(
'[runtime] failed to notify renderer after headless terminal close',
{ parentTabId: 'host-tab', error: rendererError }
)
}
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
})
@@ -550,10 +581,16 @@ describe('OrcaRuntimeService', () => {
ptyKilled: true
})
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab', {
localPtyTeardownOwnedExternally: true
})
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main closes the tab in its own layout; no renderer acknowledges anything.
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(closeTerminal).not.toHaveBeenCalled()
} else {
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab', {
localPtyTeardownOwnedExternally: true
})
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
}
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
@@ -618,6 +655,16 @@ describe('OrcaRuntimeService', () => {
]
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main's layout holds the pin, unpinned here, and main closes the tab itself.
await expect(
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
).resolves.toEqual({ closed: true })
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(kill).toHaveBeenCalledWith(servePtyId)
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
return
}
await expect(
runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
).rejects.toThrow('terminal_tab_pinned')
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { bindPaneFirst, committingSpawn, registerBoundPty } from '../bind-first-pane.test-fixture'
import {
OrcaRuntimeService,
electronMocks,
@@ -301,7 +302,11 @@ describe('OrcaRuntimeService', () => {
expect(tap.spawn.mock.calls[0]?.[0]).toMatchObject({ spawnIntent: 'user-resume' })
})
function makeRuntimeWithOtherPaneHolder(ptyIds: string[], verdict?: 'exited' | 'unverifiable') {
function makeRuntimeWithOtherPaneHolder(
ptyIds: string[],
verdict?: 'exited' | 'unverifiable',
registered = true
) {
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
const parked = makeParkedRuntime(runtimeStore, {
livePtyIds: ptyIds,
@@ -316,10 +321,17 @@ describe('OrcaRuntimeService', () => {
]
}
})
parked.runtime.registerPty('other-pty', TEST_WORKTREE_ID, null, {
tabId: 'other-tab',
leafId: HEADLESS_SECOND_LEAF_ID
})
const otherPane = { tabId: 'other-tab', leafId: HEADLESS_SECOND_LEAF_ID }
if (registered) {
registerBoundPty(parked.runtime, 'other-pty', TEST_WORKTREE_ID, null, otherPane)
} else {
// A restart: main's layout saved the pane; its terminal has not re-registered yet.
bindPaneFirst(parked.runtime, {
...otherPane,
worktreeId: TEST_WORKTREE_ID,
ptyId: 'other-pty'
})
}
return parked
}
@@ -351,18 +363,7 @@ describe('OrcaRuntimeService', () => {
})
it('does not resume while the other holder has no host terminal record yet', async () => {
const { runtimeStore } = makeParkedSessionStore('worktree-sleep')
const { runtime, spawn } = makeParkedRuntime(runtimeStore, {
deps: {
getAgentProviderSessionSnapshot: () => [
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume guard reads only paneKey and providerSession.
{
paneKey: `other-tab:${HEADLESS_SECOND_LEAF_ID}`,
providerSession: { key: 'session_id', id: 'provider-session-1' }
} as never
]
}
})
const { runtime, spawn } = makeRuntimeWithOtherPaneHolder([], undefined, false)
await userActivate(runtime)
@@ -711,7 +712,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockImplementation(async () => ({ id: `headless-pty-${++nextPty}` }))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -762,7 +763,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'headless-pty-solo' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -785,7 +786,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockImplementation(async () => ({ id: `attached-pty-${++nextPty}` }))
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { registerLivePty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import {
expectDesktopDropsPane,
expectDesktopDropsTab
} from '../__fixtures__/main-layout-close-fixture'
import {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
@@ -102,6 +108,12 @@ describe('OrcaRuntimeService', () => {
]
})
for (const [leafId, ptyId] of [
[HEADLESS_LEAF_ID, 'pty-a'],
[HEADLESS_SECOND_LEAF_ID, 'pty-b']
]) {
registerLivePty(runtime, ptyId, TEST_WORKTREE_ID, null, { tabId: 'host-tab', leafId })
}
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(kill).toHaveBeenCalledWith('pty-a')
@@ -185,6 +197,10 @@ describe('OrcaRuntimeService', () => {
]
})
registerLivePty(runtime, servePtyId, TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(kill).toHaveBeenCalledWith(servePtyId)
@@ -192,7 +208,7 @@ describe('OrcaRuntimeService', () => {
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
// Best-effort renderer notify so no adopted pane is left dead.
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expectDesktopDropsTab({ closeTerminal, getSession }, 'host-tab')
})
it('delegates a renderer-owned daemon-session (worktreeId@@uuid) local terminal to the renderer', async () => {
@@ -254,6 +270,12 @@ describe('OrcaRuntimeService', () => {
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main owns the tab's layout, so it closes the tab and stops its terminal itself.
expectDesktopDropsTab({ closeTerminal, getSession }, 'host-tab')
expect(kill).toHaveBeenCalledWith(daemonPtyId)
return
}
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expect(kill).not.toHaveBeenCalled()
// Not torn down by the runtime — left for the renderer's own close to prune.
@@ -302,7 +324,7 @@ describe('OrcaRuntimeService', () => {
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expectDesktopDropsTab({ closeTerminal, getSession }, 'host-tab')
})
it('defers a renderer-published pending tab to the renderer instead of tearing it down', async () => {
@@ -356,6 +378,12 @@ describe('OrcaRuntimeService', () => {
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main owns the tab's layout, so it closes the tab and stops the terminal it binds.
expectDesktopDropsTab({ closeTerminal, getSession }, 'host-tab')
expect(kill).toHaveBeenCalledWith('persisted-pty')
return
}
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expect(forgetTabs).not.toHaveBeenCalled()
expect(kill).not.toHaveBeenCalled()
@@ -421,6 +449,11 @@ describe('OrcaRuntimeService', () => {
// notifier that may decline it, so tombstoning would hide a live tab from
// paired clients forever. Only a host-committed close may tombstone.
expect(result).toEqual({ closed: true })
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main commits the close in its own layout, so nothing is delegated.
expectDesktopDropsTab({ closeTerminal, getSession }, 'host-tab')
return
}
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expect(forgetTabs).not.toHaveBeenCalled()
expect(kill).not.toHaveBeenCalled()
@@ -446,10 +479,15 @@ describe('OrcaRuntimeService', () => {
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(kill).not.toHaveBeenCalled()
// #31571: main's close stops the terminal its layout binds; the window-era close kills none.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(kill).toHaveBeenCalledWith('persisted-pty')
} else {
expect(kill).not.toHaveBeenCalled()
}
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
expectDesktopDropsTab({ closeTerminal, getSession }, 'host-tab')
})
it('closes only the addressed serve-owned split leaf so siblings survive even with a renderer attached', async () => {
@@ -518,6 +556,12 @@ describe('OrcaRuntimeService', () => {
]
})
for (const [leafId, ptyId] of [
[HEADLESS_LEAF_ID, 'serve-left'],
[HEADLESS_SECOND_LEAF_ID, 'serve-right']
]) {
registerLivePty(runtime, ptyId, TEST_WORKTREE_ID, null, { tabId: 'host-tab', leafId })
}
await runtime.closeMobileSessionTab(
`id:${TEST_WORKTREE_ID}`,
`host-tab::${HEADLESS_SECOND_LEAF_ID}`
@@ -527,7 +571,7 @@ describe('OrcaRuntimeService', () => {
expect(kill).toHaveBeenCalledWith('serve-right')
expect(kill).not.toHaveBeenCalledWith('serve-left')
// Only the leaf-addressed notice for the closed pane; never a whole-tab close.
expect(closeTerminalPane).toHaveBeenCalledExactlyOnceWith('host-tab', HEADLESS_SECOND_LEAF_ID)
expectDesktopDropsPane({ closeTerminalPane, getSession }, 'host-tab', HEADLESS_SECOND_LEAF_ID)
expect(closeTerminal).not.toHaveBeenCalled()
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
expect(getSession().terminalLayoutsByTabId['host-tab']?.root).toEqual({
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { committingSpawn, registerLivePty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec'
import {
@@ -199,12 +201,19 @@ describe('OrcaRuntimeService', () => {
})
it('keeps an explicit user close destructive while the PTY is live', async () => {
const { runtime, closeTerminalTab } = makeAdoptedLiveTabRuntime()
const { runtime, closeTerminalTab, kill, getSession } = makeAdoptedLiveTabRuntime()
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'user'
})
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// #31571: main closes the tab in its own layout and stops its live terminal itself.
expect(closeTerminalTab).not.toHaveBeenCalled()
expect(kill).toHaveBeenCalledWith('serve-live-1')
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
return
}
// Legacy whole-parent relay: the renderer close transaction still runs.
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')
})
@@ -309,6 +318,7 @@ describe('OrcaRuntimeService', () => {
getSession: () => WorkspaceSessionState
kill: ReturnType<typeof vi.fn>
closeTerminal: ReturnType<typeof vi.fn>
exitShell: (ptyId: string) => Promise<void>
} {
const layout = makeHeadlessTerminalLayout({
[HEADLESS_LEAF_ID]: 'serve-left',
@@ -335,12 +345,21 @@ describe('OrcaRuntimeService', () => {
)
const kill = vi.fn(() => true)
const closeTerminal = vi.fn()
const liveShells = new Set(['serve-left', 'serve-right'])
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => []
// The host lists both shells until one exits.
listProcesses: async () =>
[...liveShells].map((id) => ({
id,
title: 'shell',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
wslDistro: null
}))
})
runtime.setNotifier({ closeTerminal } as never)
runtime.syncWindowGraph(1, {
@@ -372,7 +391,17 @@ describe('OrcaRuntimeService', () => {
}
]
})
return { runtime, getSession, kill, closeTerminal }
for (const [leafId, ptyId] of [
[HEADLESS_LEAF_ID, 'serve-left'],
[HEADLESS_SECOND_LEAF_ID, 'serve-right']
]) {
registerLivePty(runtime, ptyId, TEST_WORKTREE_ID, null, { tabId: 'host-tab', leafId })
}
const exitShell = async (ptyId: string): Promise<void> => {
liveShells.delete(ptyId)
await runtime.onPtyExit(ptyId, 0)
}
return { runtime, getSession, kill, closeTerminal, exitShell }
}
it('refuses a pty-exit echoed close of a live split leaf (direct-kill branch)', async () => {
@@ -408,8 +437,8 @@ describe('OrcaRuntimeService', () => {
// parent (live sibling included), so the close must be refused — but a
// republish would re-add the dead leaf on the echoing client and feed a
// refuse→republish→re-echo loop.
const { runtime, getSession, kill, closeTerminal } = makeSplitLeafRuntime()
await runtime.onPtyExit('serve-right', 0)
const { runtime, getSession, kill, closeTerminal, exitShell } = makeSplitLeafRuntime()
await exitShell('serve-right')
const events: { worktree: string }[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
@@ -498,29 +527,34 @@ describe('OrcaRuntimeService', () => {
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
})
it('retires a dead headless tab on a pty-exit echoed close', async () => {
// Why: headless hosts have no renderer pty-exit handling of their own;
// they rely on the client echo to retire genuinely dead tab records.
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
// L255, fixed by W4's close PR: switch-on, main's tabs in `this.tabs` refuse the echo as
// retirement-owner (close-mobile-session-tab.ts:164).
;(ORCA_LAYOUT_OWNER_IS_MAIN ? it.fails : it)(
'retires a dead headless tab on a pty-exit echoed close',
async () => {
// Why: headless hosts have no renderer pty-exit handling of their own;
// they rely on the client echo to retire genuinely dead tab records.
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'pty-exit'
})
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'pty-exit'
})
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
})
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
}
)
it('retires a dead headless tab whose exited PTY still has a retained record', async () => {
// Why: onPtyExit keeps the disconnected record in ptysById for status and
@@ -601,7 +635,7 @@ describe('OrcaRuntimeService', () => {
})
} as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -636,7 +670,7 @@ describe('OrcaRuntimeService', () => {
})
} as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { bindPaneFirst, committingSpawn, registerBoundPty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, electronMocks, ipcMain } from '../orca-runtime-test-mocks.spec'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
@@ -63,7 +65,7 @@ describe('OrcaRuntimeService', () => {
})
} as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -109,7 +111,7 @@ describe('OrcaRuntimeService', () => {
})
} as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -156,7 +158,7 @@ describe('OrcaRuntimeService', () => {
})
} as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -188,7 +190,7 @@ describe('OrcaRuntimeService', () => {
})
} as never)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -296,6 +298,18 @@ describe('OrcaRuntimeService', () => {
{ sender: { send: vi.fn() } },
{ requestId: payload.requestId, error: 'spoofed renderer reply' }
)
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-renderer',
leafId: HEADLESS_LEAF_ID,
ptyId: 'pty-renderer',
viewMode: 'chat'
})
// As the renderer's spawn commit: the bound terminal registers.
registerBoundPty(runtime, 'pty-renderer', TEST_WORKTREE_ID, null, {
tabId: 'tab-renderer',
leafId: HEADLESS_LEAF_ID
})
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [
@@ -444,7 +458,12 @@ describe('OrcaRuntimeService', () => {
]
})
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
// With the switch on the window's graph does not edit main's layout, which still binds the pane.
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual(
ORCA_LAYOUT_OWNER_IS_MAIN
? [expect.objectContaining({ parentTabId: 'tab-renderer', ptyId: 'pty-renderer' })]
: []
)
})
it('dedupes concurrent mobile terminal creates that share a clientMutationId', async () => {
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn, registerBoundPty } from '../bind-first-pane.test-fixture'
import {
MOCK_GIT_WORKTREES,
OrcaRuntimeService,
@@ -161,7 +162,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-materialized' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -465,7 +466,7 @@ describe('OrcaRuntimeService', () => {
})
// The renderer's PTY spawn registers with the tab binding (as the pty IPC layer now does) after the shell-only snapshot.
runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-alive', TEST_WORKTREE_ID, null, {
tabId: 'tab-alive',
leafId
})
@@ -499,7 +500,7 @@ describe('OrcaRuntimeService', () => {
// Why: spawn and tabCreate reply are independent IPC channels; here the PTY registers before the reply, so the pre-wait check resolves it.
const webContents = { send: vi.fn() }
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
runtime.registerPty('pty-early', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-early', TEST_WORKTREE_ID, null, {
tabId: 'tab-early',
leafId
})
@@ -568,7 +569,7 @@ describe('OrcaRuntimeService', () => {
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
// Why: the pane spawned before its startup queue landed (the #7587
// renderer-stall class), so no spawn command is recorded for the PTY.
runtime.registerPty('pty-bare', TEST_WORKTREE_ID, null, { tabId: 'tab-bare', leafId })
registerBoundPty(runtime, 'pty-bare', TEST_WORKTREE_ID, null, { tabId: 'tab-bare', leafId })
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, electronMocks, ipcMain } from '../orca-runtime-test-mocks.spec'
import { TEST_REPO_ID, TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec'
import { createMobileCreateTestNotifier } from '../orca-runtime-test-scenario-builders.spec'
@@ -7,7 +8,7 @@ function makeHeadlessRuntime(runtimeStore: typeof store = store) {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-shell-pick' })
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { boundSessionOf, registerLivePty } from '../bind-first-pane.test-fixture'
import { expectDesktopDropsPane } from '../__fixtures__/main-layout-close-fixture'
import {
OrcaRuntimeService,
electronMocks,
@@ -265,6 +267,12 @@ describe('OrcaRuntimeService', () => {
})
const leftLeafId = '11111111-1111-4111-8111-111111111111'
const rightLeafId = '22222222-2222-4222-8222-222222222222'
for (const [leafId, ptyId] of [
[leftLeafId, 'pty-left'],
[rightLeafId, 'pty-right']
]) {
registerLivePty(runtime, ptyId, TEST_WORKTREE_ID, null, { tabId: 'tab-1', leafId })
}
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
@@ -320,7 +328,11 @@ describe('OrcaRuntimeService', () => {
expect(kill).toHaveBeenCalledWith('pty-right')
// Only the leaf-addressed notice for the closed pane; never a whole-tab close.
expect(closeTerminalPane).toHaveBeenCalledExactlyOnceWith('tab-1', rightLeafId)
expectDesktopDropsPane(
{ closeTerminalPane, getSession: () => boundSessionOf(runtime) },
'tab-1',
rightLeafId
)
expect(closeTerminal).not.toHaveBeenCalled()
})
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { bindPaneFirst } from '../bind-first-pane.test-fixture'
import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture'
import {
AGENT_STATUS_STALE_AFTER_MS,
@@ -511,6 +512,12 @@ describe('OrcaRuntimeService', () => {
}
]
})
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'daemon-tab',
leafId: '77777777-7777-4777-8777-777777777777',
ptyId: 'daemon-pty'
})
// paneKey-only record: the tabId rescue must not be what keeps this row.
runtime['recordPtyWorktree']('daemon-pty', TEST_WORKTREE_ID, {
connected: true,
@@ -550,6 +557,12 @@ describe('OrcaRuntimeService', () => {
}
]
})
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'daemon-tab',
leafId: '99999999-9999-4999-8999-999999999998',
ptyId: 'daemon-pty-2'
})
runtime['recordPtyWorktree']('daemon-pty-2', TEST_WORKTREE_ID, {
connected: true,
tabId: 'daemon-tab',
@@ -578,6 +591,12 @@ describe('OrcaRuntimeService', () => {
undefined,
makeAgentStatusStoreWiring().deps
)
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'osc-tab',
leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
ptyId: 'osc-pty'
})
runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, {
connected: true,
tabId: 'osc-tab',
@@ -609,6 +628,12 @@ describe('OrcaRuntimeService', () => {
})
const statusWiring = makeAgentStatusStoreWiring()
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, statusWiring.deps)
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'race-tab',
leafId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
ptyId: 'race-pty'
})
runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, {
connected: true,
tabId: 'race-tab',
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { bindPaneFirst } from '../bind-first-pane.test-fixture'
import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture'
import {
OrcaRuntimeService,
@@ -40,6 +41,13 @@ describe('OrcaRuntimeService', () => {
undefined,
makeAgentStatusStoreWiring().deps
)
// In the workspace's own partition: this fixture's worktree is local.
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'ssh-tab',
leafId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
ptyId: 'ssh-osc-pty'
})
runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, {
connected: true,
connectionId: 'ssh-osc-1',
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
import type { RuntimeTerminalAgentStatusEvent } from '../orca-runtime-test-mocks.spec'
@@ -18,7 +19,7 @@ describe('OSC 7501 program status', () => {
onTerminalAgentStatus: (event) => statuses.push(event)
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -58,7 +59,7 @@ describe('OSC 7501 program status', () => {
onTerminalProgramStatusFacet: (event) => order.push(`facet:${event.facet?.state ?? 'none'}`)
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -83,7 +84,7 @@ describe('OSC 7501 program status', () => {
facets.push([event.ptyId, event.facet?.state ?? null])
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -0,0 +1,46 @@
// Main's saved layout for the placement test: a split tab, then a trailing tab (layout-owner build).
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import {
TEST_WORKTREE_ID,
makeRuntimeStoreWithWorkspaceSession
} from '../orca-runtime-test-fixtures.spec'
export const LEFT_LEAF = '51515151-5151-4151-8151-515151515151'
export const RIGHT_LEAF = '52525252-5252-4252-8252-525252525252'
export const TRAILING_LEAF = '53535353-5353-4353-8353-535353535353'
export const SPLIT_LEFT = `split::${LEFT_LEAF}`
export const SPLIT_RIGHT = `split::${RIGHT_LEAF}`
export const TRAILING = `trailing::${TRAILING_LEAF}`
/** Main's saved layout with a split tab and a trailing tab, as the placement test's host has. */
export function splitPlacementStore() {
const tab = (id: string, sortOrder: number) => ({
id,
ptyId: null,
worktreeId: TEST_WORKTREE_ID,
title: id === 'split' ? 'Split' : 'Trailing',
customTitle: null,
color: null,
sortOrder,
createdAt: sortOrder + 1
})
const leaf = (leafId: string) => ({ type: 'leaf' as const, leafId })
return makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
activeWorktreeId: TEST_WORKTREE_ID,
tabsByWorktree: { [TEST_WORKTREE_ID]: [tab('split', 0), tab('trailing', 1)] },
terminalLayoutsByTabId: {
split: {
root: {
type: 'split',
direction: 'vertical',
first: leaf(LEFT_LEAF),
second: leaf(RIGHT_LEAF)
},
activeLeafId: LEFT_LEAF,
expandedLeafId: null
},
trailing: { root: leaf(TRAILING_LEAF), activeLeafId: TRAILING_LEAF, expandedLeafId: null }
}
}).runtimeStore
}
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { bindPaneFirst, registerBoundPty } from '../bind-first-pane.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import {
MOCK_GIT_WORKTREES,
OrcaRuntimeService,
@@ -572,7 +574,26 @@ describe('OrcaRuntimeService', () => {
)
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
// Switch point: main's layout gives every pane a UUID leaf and a window leaf vouches for no
// terminal (L80), so the live shell is the one its spawn commit registers (as the rescue in
// mobile-creation-and-orchestration.spec.ts), and the create keeps it rather than timing out.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
registerBoundPty(runtime, 'pty-stall', TEST_WORKTREE_ID, null, {
tabId: 'tab-stall',
leafId: '88888888-8888-4888-8888-888888888888'
})
await vi.advanceTimersByTimeAsync(50)
expect(await settled).toEqual({ ok: true })
expect(closeTerminal).not.toHaveBeenCalled()
return
}
// A live shell backs the tab but its leaf id isn't a terminal UUID, so no pane key registers (stalled renderer publication).
bindPaneFirst(runtime, {
worktreeId: TEST_WORKTREE_ID,
tabId: 'tab-stall',
leafId: 'pane:1',
ptyId: 'pty-stall'
})
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [
@@ -24,6 +24,7 @@ import {
RESTORED_AUTHORITY_TOKEN,
RESTORED_AUTHORITY_TOKEN_HASH,
TEST_FOLDER_WORKSPACE_KEY,
TEST_REPO_ID,
TEST_WINDOW_ID,
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
@@ -86,8 +87,15 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(
{
...store,
// The worker's repo lives on the SSH target whose partition holds its session.
getRepos: () => [{ ...store.getRepo(TEST_REPO_ID)!, connectionId: targetId }],
getRepo: (id: string) =>
id === TEST_REPO_ID
? { ...store.getRepo(TEST_REPO_ID)!, connectionId: targetId }
: undefined,
getWorkspaceSession: (hostId?: string | null) =>
hostId === `ssh:${targetId}` ? sshSession : getDefaultWorkspaceSession()
hostId === `ssh:${targetId}` ? sshSession : getDefaultWorkspaceSession(),
getWorkspaceSessionHostIds: () => ['local', `ssh:${targetId}`]
},
undefined,
{
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn, registerLivePty } from '../bind-first-pane.test-fixture'
import {
OrcaRuntimeService,
getDefaultWorkspaceSession,
@@ -85,6 +86,10 @@ describe('OrcaRuntimeService', () => {
}
]
})
registerLivePty(runtime, 'pty-source', TEST_WORKTREE_ID, null, {
tabId,
leafId: sourceLeafId
})
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
expect(sourceHandle).not.toBeNull()
@@ -209,7 +214,7 @@ describe('OrcaRuntimeService', () => {
const splitTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -455,7 +460,10 @@ describe('OrcaRuntimeService', () => {
})
)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(store)
const { runtimeStore, setSession } = makeRuntimeStoreWithWorkspaceSession(
getDefaultWorkspaceSession()
)
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
@@ -469,6 +477,8 @@ describe('OrcaRuntimeService', () => {
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
// The source pane closes in main's layout while the split starts.
setSession(getDefaultWorkspaceSession())
resolveSplitSpawn({ id: 'retired-projected-split-pty' })
await expect(split).rejects.toThrow('terminal_split_source_not_found')
@@ -489,7 +499,7 @@ describe('OrcaRuntimeService', () => {
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import {
AGENT_PROMPT_BRACKETED_PASTE_END,
@@ -369,7 +370,7 @@ describe('OrcaRuntimeService', () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'pty-bg' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -412,7 +413,7 @@ describe('OrcaRuntimeService', () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'pty-bg' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -543,7 +544,7 @@ describe('OrcaRuntimeService', () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession())
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'pty-bg' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn, registerLivePty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, electronMocks, makePaneKey } from '../orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
@@ -241,7 +242,7 @@ describe('OrcaRuntimeService', () => {
retireAgentHookCompatibilityAuthority: retireAuthority
})
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -407,6 +408,11 @@ describe('OrcaRuntimeService', () => {
}
]
})
registerLivePty(runtime, 'persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
incarnationId
})
const syntheticHandle = runtime.getAgentStatusTerminalHandleForPaneKey(paneKey)
expect(syntheticHandle).toMatch(/^term_/)
expect(syntheticHandle).not.toBe(controllerHandle)
@@ -1,5 +1,6 @@
import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../../ipc/pty/ipc/spawn-types'
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
@@ -458,7 +459,7 @@ describe('OrcaRuntimeService', () => {
const getForegroundProcess = vi.fn(async () => 'omp')
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import {
AGENT_STATUS_STALE_AFTER_MS,
OrcaRuntimeService,
@@ -347,7 +348,7 @@ describe('OrcaRuntimeService', () => {
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess
@@ -396,7 +397,7 @@ describe('OrcaRuntimeService', () => {
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess
@@ -455,7 +456,7 @@ describe('OrcaRuntimeService', () => {
.mockReturnValueOnce(freshForegroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess
@@ -518,7 +519,7 @@ describe('OrcaRuntimeService', () => {
.mockReturnValueOnce(freshForegroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess
@@ -577,7 +578,7 @@ describe('OrcaRuntimeService', () => {
const getForegroundProcess = vi.fn(async () => 'pi')
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
kill: () => true,
getForegroundProcess
@@ -1,5 +1,6 @@
import { withDurableRuntimeStore } from '../runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { registerLivePty } from '../bind-first-pane.test-fixture'
import {
OrcaRuntimeService,
advertisedUrlWatcher,
@@ -92,6 +93,10 @@ describe('OrcaRuntimeService', () => {
}
]
})
registerLivePty(runtime, 'visual-pty', TEST_WORKTREE_ID, null, {
tabId: 'occupied-tab',
leafId: HEADLESS_LEAF_ID
})
runtime.setPtyController({
write: () => true,
kill: () => true,
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { movePaneFirst, registerBoundPty } from '../bind-first-pane.test-fixture'
import {
HEADLESS_LEAF_ID,
TEST_WINDOW_ID,
@@ -160,7 +161,7 @@ describe('OrcaRuntimeService', () => {
const appPtyId = 'ssh:ssh-target@@pty-8'
const runtime = createRuntimeWithSshLease(appPtyId, leaseTabId)
const paneKey = makePaneKey(currentTabId, HEADLESS_LEAF_ID)
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId: currentTabId,
leafId: HEADLESS_LEAF_ID
})
@@ -196,12 +197,18 @@ describe('OrcaRuntimeService', () => {
const appPtyId = 'ssh:ssh-target@@pty-9'
const runtime = createRuntimeWithSshLease(appPtyId, leaseTabId)
const stalePaneKey = makePaneKey(leaseTabId, HEADLESS_LEAF_ID)
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId: leaseTabId,
leafId: HEADLESS_LEAF_ID
})
const staleHandle = runtime.resolveTerminalPane(stalePaneKey, TEST_WORKTREE_ID).handle
// The move: same leaf, same PTY, new tab.
movePaneFirst(runtime, leaseTabId, {
worktreeId: TEST_WORKTREE_ID,
tabId: currentTabId,
leafId: HEADLESS_LEAF_ID,
ptyId: appPtyId
})
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId: currentTabId,
leafId: HEADLESS_LEAF_ID
@@ -1,4 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import {
dropFromHostInventory,
registerBoundPty,
registerLivePty
} from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService, makePaneKey } from '../orca-runtime-test-mocks.spec'
import type {
RuntimeClientEvent,
@@ -155,6 +160,10 @@ describe('OrcaRuntimeService', () => {
}
]
}
registerLivePty(runtime, 'pty-1', 'repo-1::/tmp/worktree-a', null, {
tabId: 'tab-1',
leafId: 'pane:1'
})
runtime.syncWindowGraph(1, graph)
const listed = await runtime.listMobileSessionTabs('branch:feature/foo')
const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal')
@@ -360,12 +369,12 @@ describe('OrcaRuntimeService', () => {
const tabId = 'tab-recover'
const runtime = createRuntimeWithSshLease('pty-expired', tabId)
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty('pty-expired', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-expired', TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
})
const expiredHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit('pty-expired', 0)
await dropFromHostInventory(runtime, 'pty-expired')
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
handle: 'term-replacement',
tabId,
@@ -417,7 +426,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(store)
const tabId = 'tab-live'
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty('pty-live', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-live', TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
})
@@ -437,13 +446,13 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(store)
const tabId = 'tab-cas'
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty('pty-old', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-old', TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
})
const oldHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit('pty-old', 0)
runtime.registerPty('pty-new', TEST_WORKTREE_ID, null, {
await dropFromHostInventory(runtime, 'pty-old')
registerBoundPty(runtime, 'pty-new', TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
})
@@ -460,12 +469,12 @@ describe('OrcaRuntimeService', () => {
const tabId = 'tab-concurrent'
const runtime = createRuntimeWithSshLease('pty-expired', tabId)
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty('pty-expired', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-expired', TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
})
const expiredHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit('pty-expired', 0)
await dropFromHostInventory(runtime, 'pty-expired')
let finishCreate!: (result: RuntimeTerminalCreate) => void
const pendingCreate = new Promise<RuntimeTerminalCreate>((resolve) => {
finishCreate = resolve
@@ -493,12 +502,12 @@ describe('OrcaRuntimeService', () => {
const tabId = 'tab-retry'
const runtime = createRuntimeWithSshLease('pty-expired', tabId)
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty('pty-expired', TEST_WORKTREE_ID, null, {
registerBoundPty(runtime, 'pty-expired', TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
})
const expiredHandle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit('pty-expired', 0)
await dropFromHostInventory(runtime, 'pty-expired')
const createTerminal = vi
.spyOn(runtime, 'createTerminal')
.mockRejectedValueOnce(new Error('relay_reconnecting'))
@@ -528,12 +537,12 @@ describe('OrcaRuntimeService', () => {
const appPtyId = 'ssh:ssh-target@@pty-terminated'
const runtime = createRuntimeWithSshLease(appPtyId, tabId, 'terminated')
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId,
leafId: HEADLESS_LEAF_ID
})
const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit(appPtyId, 0)
await dropFromHostInventory(runtime, appPtyId)
const createTerminal = vi.spyOn(runtime, 'createTerminal')
await expect(runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)).rejects.toThrow(
@@ -551,12 +560,12 @@ describe('OrcaRuntimeService', () => {
const appPtyId = 'ssh:ssh-target@@pty-3'
const runtime = createRuntimeWithSshLease(appPtyId, tabId)
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId,
leafId: HEADLESS_LEAF_ID
})
const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit(appPtyId, 0)
await dropFromHostInventory(runtime, appPtyId)
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
handle: 'term-replacement',
tabId,
@@ -589,12 +598,12 @@ describe('OrcaRuntimeService', () => {
supersededBy: 'pty-6'
})
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId,
leafId: HEADLESS_LEAF_ID
})
const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit(appPtyId, 0)
await dropFromHostInventory(runtime, appPtyId)
const createTerminal = vi.spyOn(runtime, 'createTerminal')
await expect(runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)).rejects.toThrow(
@@ -610,12 +619,12 @@ describe('OrcaRuntimeService', () => {
relayIdRecycled: true
})
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty(appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId,
leafId: HEADLESS_LEAF_ID
})
const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
runtime.onPtyExit(appPtyId, 0)
await dropFromHostInventory(runtime, appPtyId)
const createTerminal = vi.spyOn(runtime, 'createTerminal')
await expect(runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)).rejects.toThrow(
@@ -637,7 +646,7 @@ describe('OrcaRuntimeService', () => {
const ptyId = 'ssh:ssh-target@@pty-3'
const runtime = createRuntimeWithSshLease(ptyId, tabId)
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty(ptyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, ptyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId,
leafId: HEADLESS_LEAF_ID
})
@@ -669,7 +678,7 @@ describe('OrcaRuntimeService', () => {
const ptyId = 'ssh:ssh-target@@pty-4'
const runtime = createRuntimeWithSshLease(ptyId, tabId)
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
runtime.registerPty(ptyId, TEST_WORKTREE_ID, 'ssh-target', {
registerBoundPty(runtime, ptyId, TEST_WORKTREE_ID, 'ssh-target', {
tabId,
leafId: HEADLESS_LEAF_ID
})
@@ -1,5 +1,6 @@
import { withDurableRuntimeStore } from '../runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import {
OrcaRuntimeService,
getDefaultWorkspaceSession,
@@ -207,10 +208,13 @@ describe('OrcaRuntimeService', () => {
}))
})
expect(secondClient).toMatchObject({ adopted: false, topologyRevision: 1 })
expect(agentPty).toMatchObject({
tabId: 'tab-agent',
paneKey: makePaneKey('tab-agent', HEADLESS_LEAF_ID)
})
// Switch-on, main's layout holds the binding (listed below); the record copies are never written (L80).
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(agentPty).toMatchObject({
tabId: 'tab-agent',
paneKey: makePaneKey('tab-agent', HEADLESS_LEAF_ID)
})
}
expect(listProcesses).toHaveBeenCalledTimes(inventoryCount + 1)
expect(listProcesses).toHaveBeenLastCalledWith('local', LIST_PROVIDER_DEADLINE)
expect(
@@ -570,7 +574,12 @@ describe('OrcaRuntimeService', () => {
expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith(workerPaneKey, 'adopted')
expect(write).not.toHaveBeenCalled()
expect(kill).not.toHaveBeenCalled()
const [terminal] = (await runtime.listTerminals()).terminals
const terminal = (await runtime.listTerminals()).terminals.find(
(candidate) => candidate.ptyId === 'pty-legacy'
)
if (!terminal) {
throw new Error('expected the legacy worker terminal')
}
await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({
tail: [' >_ OpenAI Codex (v0.131.0)', ' model: gpt-5.5 high', ' directory: /repo']
})
@@ -1,5 +1,6 @@
import { withDurableRuntimeStore } from '../runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec'
import type { OrchestrationDb } from '../orchestration/db'
import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec'
@@ -393,6 +394,10 @@ describe('OrcaRuntimeService', () => {
).toBeUndefined()
expect(harness.getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(harness.getSession().terminalLayoutsByTabId['legacy-post-reveal']).toBeUndefined()
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// The window draws main's layout, which no longer holds the tab: its next graph omits it.
harness.runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [], leaves: [] })
}
const runtimeState = harness.runtime as unknown as {
tabs: Map<string, unknown>
leaves: Map<string, unknown>
@@ -88,6 +88,8 @@ describe('OrcaRuntimeService', () => {
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined),
getWorkspaceSession,
setWorkspaceSession,
// Every partition the store holds, as the profile Store lists them.
getWorkspaceSessionHostIds: () => ['local' as const, `ssh:${connectionId}` as const],
flushOrThrow: vi.fn()
}),
undefined,
@@ -1,6 +1,7 @@
import { settledWriteStub } from '../../providers/settled-pty-write-stub'
import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture'
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import {
OrcaRuntimeService,
detectAgentStatusFromTitle,
@@ -105,7 +106,7 @@ describe('OrcaRuntimeService', () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
writeWithSettlement: settledWriteStub(() => true),
kill: () => true,
@@ -144,7 +145,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps)
const uninstallRepublish = statusWiring.attach(runtime)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
writeWithSettlement: settledWriteStub(() => true),
kill: () => true,
@@ -201,7 +202,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps)
const uninstallRepublish = statusWiring.attach(runtime)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
writeWithSettlement: settledWriteStub(() => true),
kill: () => true,
@@ -255,7 +256,7 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps)
const uninstallRepublish = statusWiring.attach(runtime)
runtime.setPtyController({
spawn,
spawn: committingSpawn(runtime, spawn),
write: () => true,
writeWithSettlement: settledWriteStub(() => true),
kill: () => true,
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { committingSpawn } from '../bind-first-pane.test-fixture'
import { HEADLESS_RUNTIME_WINDOW_ID, OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import type { RuntimeClientEvent } from '../orca-runtime-test-mocks.spec'
import {
@@ -376,7 +377,7 @@ describe('terminal side-effect fact channel', () => {
it('lets an explicit terminal rename override cached Cursor identity and restores it after clearing', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'pty-1' }))),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -191,7 +191,8 @@ describe('OrcaRuntimeService', () => {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: targetPtyId })
}
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
// Sessions are saved to the partition the workspace routes to: its repo's host.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session, 'ssh:conn-stale')
const repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'conn-stale' }
runtimeStore.getRepos = () => [repo]
runtimeStore.getRepo = (id: string) => (id === TEST_REPO_ID ? repo : undefined)
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { registerLivePty } from '../bind-first-pane.test-fixture'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec'
import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture'
@@ -248,6 +250,11 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
it('keeps runtime-owned legacy OSC rows in worktree.ps and mobile projections', async () => {
const statusWiring = makeAgentStatusStoreWiring()
const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps)
// Switch-on a saved legacy pane id is reminted to a UUID at load (store.ts:115, then
// terminal-layout-normalization.ts:149-160), so main's layout never holds `pane:7` (L80 E5).
const leafId = ORCA_LAYOUT_OWNER_IS_MAIN ? '77777777-7777-4777-8777-777777777777' : 'pane:7'
const paneKey = ORCA_LAYOUT_OWNER_IS_MAIN ? `legacy-tab:${leafId}` : 'legacy-tab:7'
registerLivePty(runtime, 'legacy-pty', TEST_WORKTREE_ID, null, { tabId: 'legacy-tab', leafId })
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
@@ -255,7 +262,7 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
tabId: 'legacy-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
activeLeafId: 'pane:7',
activeLeafId: leafId,
layout: null
}
],
@@ -263,7 +270,7 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
{
tabId: 'legacy-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:7',
leafId,
paneRuntimeId: 7,
ptyId: 'legacy-pty'
}
@@ -274,14 +281,14 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
publicationEpoch: 'legacy-epoch',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: 'legacy-tab::pane:7',
activeTabId: `legacy-tab::${leafId}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'legacy-tab::pane:7',
id: `legacy-tab::${leafId}`,
parentTabId: 'legacy-tab',
leafId: 'pane:7',
leafId,
ptyId: 'legacy-pty',
title: 'Codex',
isActive: true
@@ -301,10 +308,10 @@ describe('worktree ps follows a dismissal out of the agent-status store', () =>
expect(
listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents
).toEqual([expect.objectContaining({ paneKey: 'legacy-tab:7', prompt: 'legacy task' })])
).toEqual([expect.objectContaining({ paneKey, prompt: 'legacy task' })])
expect(mobile.tabs[0]).toMatchObject({
type: 'terminal',
agentStatus: { paneKey: 'legacy-tab:7', prompt: 'legacy task' }
agentStatus: { paneKey, prompt: 'legacy task' }
})
runtime.onPtyExit('legacy-pty', 0)
expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([])
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import {
HEADLESS_LEAF_ID,
TEST_REPO_ID,
@@ -35,7 +36,11 @@ function makeReattachedRelayRuntime(): OrcaRuntimeService {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: REATTACHED_PTY_ID })
}
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
// The real Store keeps an SSH workspace's session in its host's partition.
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
session,
toSshExecutionHostId(CONNECTION_ID)
)
const repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: CONNECTION_ID }
runtimeStore.getRepos = () => [repo]
runtimeStore.getRepo = (id: string) => (id === TEST_REPO_ID ? repo : undefined)
-5
View File
@@ -241,11 +241,6 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase {
return this.resolveAgentTerminalCreateOptions(workspace, opts)
}
/** Whether a running process holds this pane now: such a pane is attached to, never launched into. */
hasLiveTerminalForPaneKey(paneKey: string): boolean {
return this.getPtyRecordForPaneKey(paneKey)?.connected === true
}
private readonly workspaceLayoutStream = new WorkspaceLayoutStream({
store: () => this.store,
homeHostId: (key) => this.tryGetWorkspaceSessionHostIdForWorktree(key),
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
import { OrchestrationDb } from './orchestration/db'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import {
checkBoundMailbox,
createBoundRun,
@@ -472,46 +473,51 @@ describe('orchestration notification mailbox consistency', () => {
db.close()
})
it('serializes equivalent panes while newer mail arrives during Enter delay', async () => {
vi.useFakeTimers()
const db = new OrchestrationDb(':memory:')
const harness = createRuntime(db)
const run = createBoundRun(db, 'Equivalent pane Run')
const first = insertDirectRunMessage(db, run.id, 'First status')
await driveToLiveIdle(harness.runtime)
expect(pointerCount(harness.write)).toBe(1)
// Window-owned only: main's layout load re-mints a leaf id a second tab repeats, so two live
// panes never share a leaf there (workspace-layout-load-bindings.ts loadLeaves).
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
'serializes equivalent panes while newer mail arrives during Enter delay',
async () => {
vi.useFakeTimers()
const db = new OrchestrationDb(':memory:')
const harness = createRuntime(db)
const run = createBoundRun(db, 'Equivalent pane Run')
const first = insertDirectRunMessage(db, run.id, 'First status')
await driveToLiveIdle(harness.runtime)
expect(pointerCount(harness.write)).toBe(1)
registerSecondPane(harness.runtime, LEAF_ID)
const second = db.insertMessage({
from: 'term_worker',
to: `run:${run.id}`,
subject: 'Second status',
runId: run.id
})
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex working\x07', 1)
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex done\x07', 2)
await Promise.resolve()
expect(
harness.write.mock.calls.filter(
([ptyId, payload]) => ptyId === SECOND_PTY_ID && isMailboxPointer(payload)
registerSecondPane(harness.runtime, LEAF_ID)
const second = db.insertMessage({
from: 'term_worker',
to: `run:${run.id}`,
subject: 'Second status',
runId: run.id
})
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex working\x07', 1)
harness.runtime.onPtyData(SECOND_PTY_ID, '\x1b]0;Codex done\x07', 2)
await Promise.resolve()
expect(
harness.write.mock.calls.filter(
([ptyId, payload]) => ptyId === SECOND_PTY_ID && isMailboxPointer(payload)
)
).toHaveLength(0)
await vi.advanceTimersByTimeAsync(500)
expect(
harness.write.mock.calls.filter(
([ptyId, payload]) => ptyId === PTY_ID && isMailboxPointer(payload)
)
).toHaveLength(2)
await vi.advanceTimersByTimeAsync(500)
const checked = await checkBoundMailbox(harness.runtime)
expect(checked).toMatchObject({ runId: run.id, count: 2 })
expect(checked.messages).toEqual(
[first, second].map((message) => expect.objectContaining({ id: message.id }))
)
).toHaveLength(0)
await vi.advanceTimersByTimeAsync(500)
expect(
harness.write.mock.calls.filter(
([ptyId, payload]) => ptyId === PTY_ID && isMailboxPointer(payload)
)
).toHaveLength(2)
await vi.advanceTimersByTimeAsync(500)
const checked = await checkBoundMailbox(harness.runtime)
expect(checked).toMatchObject({ runId: run.id, count: 2 })
expect(checked.messages).toEqual(
[first, second].map((message) => expect.objectContaining({ id: message.id }))
)
db.close()
})
db.close()
}
)
it.each([
['Codex', '\x1b]0;Codex working\x07', '\x1b]0;Codex done\x07'],
@@ -10,6 +10,9 @@ import { OrcaRuntimeService } from './orca-runtime'
import { OrchestrationDb } from './orchestration/db'
import { RpcDispatcher } from './rpc/dispatcher'
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
import type { RuntimeStore } from './runtime-store-contract'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { bindPaneFirst, registerBoundPty } from './bind-first-pane.test-fixture'
export const TAB_ID = '11111111-1111-4111-8111-111111111111'
export const LEAF_ID = '22222222-2222-4222-8222-222222222222'
@@ -83,6 +86,28 @@ export type MailboxCheckOptions = {
signal?: AbortSignal
}
/** Layout-owner builds: an empty store whose session holds main's layout, which binds the panes. */
function mailboxLayoutStore(): RuntimeStore {
return {
getRepos: () => [],
getRepo: () => undefined,
addRepo: vi.fn<RuntimeStore['addRepo']>(),
updateRepo: vi.fn<RuntimeStore['updateRepo']>(),
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn<RuntimeStore['setWorktreeMeta']>(),
removeWorktreeMeta: vi.fn<RuntimeStore['removeWorktreeMeta']>(),
getGitHubCache: vi.fn<RuntimeStore['getGitHubCache']>(),
getSettings: () => ({
workspaceDir: '/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
}
}
export function createRuntime(
db: OrchestrationDb,
options: {
@@ -92,14 +117,18 @@ export function createRuntime(
checkHookAgentPresence?: (paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>
} = {}
): MailboxNotificationHarness {
const runtime = new OrcaRuntimeService(null, undefined, {
getAgentStatusSnapshot: options.getAgentStatusSnapshot,
checkHookAgentPresence: options.checkHookAgentPresence,
attestAgentHookCompatibilityAuthority: ({ paneKey }) =>
paneKey === PANE_KEY || paneKey.startsWith(`${SECOND_TAB_ID}:`)
? { paneKey, source: 'current_hook' }
: null
})
const runtime = new OrcaRuntimeService(
ORCA_LAYOUT_OWNER_IS_MAIN ? mailboxLayoutStore() : null,
undefined,
{
getAgentStatusSnapshot: options.getAgentStatusSnapshot,
checkHookAgentPresence: options.checkHookAgentPresence,
attestAgentHookCompatibilityAuthority: ({ paneKey }) =>
paneKey === PANE_KEY || paneKey.startsWith(`${SECOND_TAB_ID}:`)
? { paneKey, source: 'current_hook' }
: null
}
)
const write = vi.fn(() => true)
runtime.setOrchestrationDb(db)
runtime.setPtyController({
@@ -108,6 +137,7 @@ export function createRuntime(
kill: vi.fn(),
getForegroundProcess: async () => null
})
bindPaneFirst(runtime, { worktreeId: WORKTREE_ID, tabId: TAB_ID, leafId: LEAF_ID, ptyId: PTY_ID })
runtime.registerPty(
PTY_ID,
WORKTREE_ID,
@@ -150,7 +180,7 @@ export function registerSecondPane(
leafId = SECOND_LEAF_ID,
includeFirstPane = true
): void {
runtime.registerPty(SECOND_PTY_ID, WORKTREE_ID, null, {
registerBoundPty(runtime, SECOND_PTY_ID, WORKTREE_ID, null, {
tabId: SECOND_TAB_ID,
leafId,
incarnationId: 'mailbox-second-incarnation',
@@ -89,6 +89,7 @@ function buildProjection(db: OrchestrationDb): RuntimeAgentOrchestrationProjecti
getDb: () => db,
getLeaves: () => leaves,
getPtys: () => [],
paneOf: () => ({ tabId: null, paneKey: null, recordedAtGraphSequence: 0 }),
issueLeafHandle: (leaf) => handleByLeaf.get(leaf) ?? '',
issuePtyHandle: () => '',
makePaneKey: (leaf) => paneByLeaf.get(leaf) ?? '',
@@ -190,7 +190,7 @@ export abstract class OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller exte
terminalHandle: string,
incarnationId: string
): void {
const paneKey = pty.paneKey
const { paneKey } = this.paneOfPty(pty)
const hostScope = this.getOrchestrationCompatibilityHostScope(pty)
if (!paneKey || !parsePaneKey(paneKey) || !hostScope) {
this.restoredOrchestrationAuthorityByPtyId.delete(pty.ptyId)
@@ -51,11 +51,12 @@ export abstract class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRu
return null
}
const pty = this.ptysById.get(ptyId)
const pane = pty?.connected ? this.paneOfPty(pty) : null
const leaf = parked
? pty?.connected &&
pty.tabId === stagedLeaf.tabId &&
pane?.tabId === stagedLeaf.tabId &&
isTerminalLeafId(stagedLeaf.leafId) &&
pty.paneKey === makePaneKey(stagedLeaf.tabId, stagedLeaf.leafId)
pane.paneKey === makePaneKey(stagedLeaf.tabId, stagedLeaf.leafId)
? {
...stagedLeaf,
writable: true,
@@ -47,8 +47,9 @@ export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeW
}
let newestMatch: RuntimePtyWorktreeRecord | null = null
for (const pty of this.ptysById.values()) {
const ptyPane = parsePaneKey(pty.paneKey ?? '')
if (pty.paneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) {
const recordedPaneKey = this.paneOfPty(pty).paneKey
const ptyPane = parsePaneKey(recordedPaneKey ?? '')
if (recordedPaneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) {
if (pty.connected) {
return pty
}
@@ -59,15 +60,12 @@ export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeW
}
protected getPaneKeyForTerminalHandle(handle: string): string | null {
const livePty = this.getLivePtyForHandle(handle)
if (livePty?.pty.paneKey) {
return livePty.pty.paneKey
const livePaneKey = this.paneOfPty(this.getLivePtyForHandle(handle)?.pty).paneKey
if (livePaneKey) {
return livePaneKey
}
const record = this.handles.get(handle)
if (!record || record.runtimeId !== this.runtimeId) {
return null
}
if (!isTerminalLeafId(record.leafId)) {
if (!record || record.runtimeId !== this.runtimeId || !isTerminalLeafId(record.leafId)) {
return null
}
return makePaneKey(record.tabId, record.leafId)

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