mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
One write path for main's split panes, late-shown splits reported again (dark, build switch off by default) (#29723)
* One pane-commit path for main's splits, a late-mounted split recorded again, and one fresh-pane exit rule (dark, build switch off by default) * Runtime panes write only touched records in the layout-owner build; main keeps a split the window already recorded * Admission patch that finds no change writes nothing, as layout.apply
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import { leafIds, WindowSession } from './terminal-topology-window-session-fixture'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_GIT_WORKTREE_ID,
|
||||
openTopologyStore
|
||||
} from './terminal-topology-profile-fixture'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
const WORKTREE = FIXTURE_GIT_WORKTREE_ID
|
||||
const TAB = 'tab-1'
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const NEW_LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
/** One tab with leftovers a full load-and-save rewrite would drop or renumber. */
|
||||
async function storeWithLeftovers(): Promise<Store> {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-pane-admission-'))
|
||||
directories.push(directory)
|
||||
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
|
||||
const window = new WindowSession(getDefaultWorkspaceSession())
|
||||
window.addTab(WORKTREE, TAB, LEAF)
|
||||
window.bind(TAB, LEAF, 'pty-1')
|
||||
window.session.terminalPtyIncarnationsByPaneKey = { [`${TAB}:${LEAF}`]: 'incarnation-1' }
|
||||
window.session.tabsByWorktree[WORKTREE]![0]!.sortOrder = 7
|
||||
store.setWorkspaceSession(window.snapshot())
|
||||
return store
|
||||
}
|
||||
|
||||
const admission = {
|
||||
type: 'splitPane',
|
||||
workspace: WORKTREE,
|
||||
tabId: TAB,
|
||||
leafId: LEAF,
|
||||
direction: 'vertical',
|
||||
newLeafId: NEW_LEAF
|
||||
} as const
|
||||
|
||||
const root = (store: Store) => store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root
|
||||
|
||||
describe('a runtime pane in a layout-owner build', () => {
|
||||
it('is written and taken back touching no other record', async () => {
|
||||
const store = await storeWithLeftovers()
|
||||
const stored = store.getWorkspaceSession()
|
||||
|
||||
await expect(store.admitTerminalPane(admission)).resolves.toBe('admitted')
|
||||
const admitted = store.getWorkspaceSession()
|
||||
expect(leafIds(root(store))).toEqual([LEAF, NEW_LEAF])
|
||||
expect(admitted.terminalPtyIncarnationsByPaneKey).toEqual(
|
||||
stored.terminalPtyIncarnationsByPaneKey
|
||||
)
|
||||
expect(admitted.tabsByWorktree).toEqual(stored.tabsByWorktree)
|
||||
|
||||
await store.withdrawTerminalPane({
|
||||
type: 'closePane',
|
||||
workspace: WORKTREE,
|
||||
tabId: TAB,
|
||||
leafId: NEW_LEAF
|
||||
})
|
||||
const withdrawn = store.getWorkspaceSession()
|
||||
expect(leafIds(root(store))).toEqual([LEAF])
|
||||
expect(withdrawn.tabsByWorktree).toEqual(stored.tabsByWorktree)
|
||||
expect(withdrawn.terminalPtyIncarnationsByPaneKey).toEqual(
|
||||
stored.terminalPtyIncarnationsByPaneKey
|
||||
)
|
||||
})
|
||||
|
||||
it('comes back when the window records it after main took it back, and only once', async () => {
|
||||
const store = await storeWithLeftovers()
|
||||
const { newLeafId: _newLeafId, ...split } = admission
|
||||
const record = { ...split, newLeafId: NEW_LEAF }
|
||||
await store.admitTerminalPane(admission)
|
||||
|
||||
// Main still holds it: the window's record changes nothing.
|
||||
await store.applyLayoutCommand(record)
|
||||
expect(leafIds(root(store))).toEqual([LEAF, NEW_LEAF])
|
||||
|
||||
await store.withdrawTerminalPane({
|
||||
type: 'closePane',
|
||||
workspace: WORKTREE,
|
||||
tabId: TAB,
|
||||
leafId: NEW_LEAF
|
||||
})
|
||||
await store.applyLayoutCommand(record)
|
||||
expect(leafIds(root(store))).toEqual([LEAF, NEW_LEAF])
|
||||
await store.applyLayoutCommand(record)
|
||||
expect(leafIds(root(store))).toEqual([LEAF, NEW_LEAF])
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
followRemoteSessionAfterPaneDetach
|
||||
} from './pane-remote-session-follow'
|
||||
import { pruneClosedTerminalTabTombstones } from '../../../shared/closed-terminal-tab-tombstones'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
|
||||
/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */
|
||||
export type TerminalPaneAdmission =
|
||||
@@ -52,7 +53,7 @@ export function admitPaneToSession(
|
||||
if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) {
|
||||
return { value: 'exists', session: null }
|
||||
}
|
||||
const change = applyToSession(loaded, admission)
|
||||
const change = applyToSession(loaded, admission, touchedRecordsWrite(session, loaded))
|
||||
return { ...change, value: change.value.ok ? 'admitted' : change.value.code }
|
||||
}
|
||||
|
||||
@@ -61,10 +62,25 @@ export function withdrawPaneFromSession(
|
||||
session: WorkspaceSessionState,
|
||||
pane: CommandOf<'closePane'>
|
||||
): PaneLayoutChange<LayoutRefusalCode | null> {
|
||||
const change = applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane)
|
||||
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
|
||||
const change = applyToSession(loaded, pane, touchedRecordsWrite(session, loaded))
|
||||
return { ...change, value: change.value.ok ? null : change.value.code }
|
||||
}
|
||||
|
||||
/** Switch point: in a layout-owner build a runtime pane writes only the records it changes, as
|
||||
* `layout.apply` does; window-owned builds rewrite the whole partition. */
|
||||
function touchedRecordsWrite(
|
||||
session: WorkspaceSessionState,
|
||||
loaded: LoadedWorkspaceLayout
|
||||
): ((after: WorkspaceSessionState) => WorkspaceSessionState | null) | undefined {
|
||||
return ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? (after) => {
|
||||
const patched = patchStoredSession(session, saveWorkspaceLayout(loaded), after)
|
||||
return patched === session ? null : patched
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A client's layout command (`layout.apply`), with main minting every new id. Only the records the
|
||||
* command changed are written; the rest of the partition stays as stored, normalized or not.
|
||||
|
||||
@@ -172,6 +172,9 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog {
|
||||
|
||||
/** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */
|
||||
async applyLayoutCommand(command: WindowLayoutCommand): Promise<LayoutApplyReply> {
|
||||
if (command.type === 'splitPane' && command.newLeafId) {
|
||||
this.noteWindowMountedSplit(command.newLeafId)
|
||||
}
|
||||
const reply = await applyWindowLayoutCommand(
|
||||
this.requireStore(),
|
||||
(worktreeId, tabId) => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalPaneAdmissionOutcome } from '../persistence/terminal-topology/terminal-pane-admission'
|
||||
import { startInAdmittedPane } from './runtime-pane-admission'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
const SPLIT = {
|
||||
type: 'splitPane',
|
||||
workspace: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
direction: 'vertical',
|
||||
newLeafId: 'leaf-new'
|
||||
} as const
|
||||
|
||||
function storeAnswering(admit: TerminalPaneAdmissionOutcome | Error) {
|
||||
const store = {
|
||||
admitTerminalPane: vi.fn(async () => {
|
||||
if (admit instanceof Error) {
|
||||
throw admit
|
||||
}
|
||||
return admit
|
||||
}),
|
||||
withdrawTerminalPane: vi.fn(async () => null)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission path calls only these two store methods.
|
||||
return { store, runtimeStore: store as unknown as RuntimeStore }
|
||||
}
|
||||
|
||||
describe('startInAdmittedPane', () => {
|
||||
it('writes the pane, then starts what fills it', async () => {
|
||||
const { store, runtimeStore } = storeAnswering('admitted')
|
||||
const start = vi.fn(async () => 'term-1')
|
||||
|
||||
await expect(startInAdmittedPane(runtimeStore, SPLIT, 'ssh:target-1', start)).resolves.toEqual({
|
||||
outcome: 'admitted',
|
||||
result: 'term-1'
|
||||
})
|
||||
expect(store.admitTerminalPane).toHaveBeenCalledWith(SPLIT, 'ssh:target-1')
|
||||
expect(start).toHaveBeenCalledWith('admitted')
|
||||
})
|
||||
|
||||
it('takes back the pane it wrote when the start fails and no client shows it', async () => {
|
||||
const { store, runtimeStore } = storeAnswering('admitted')
|
||||
|
||||
await expect(
|
||||
startInAdmittedPane(runtimeStore, SPLIT, undefined, async () => {
|
||||
throw new Error('mount timed out')
|
||||
})
|
||||
).rejects.toThrow('mount timed out')
|
||||
expect(store.withdrawTerminalPane).toHaveBeenCalledWith(
|
||||
{ type: 'closePane', workspace: 'wt-1', tabId: 'tab-1', leafId: 'leaf-new' },
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a pane a client already shows, and one it did not write', async () => {
|
||||
const shown = storeAnswering('admitted')
|
||||
await expect(
|
||||
startInAdmittedPane(
|
||||
shown.runtimeStore,
|
||||
SPLIT,
|
||||
undefined,
|
||||
async () => {
|
||||
throw new Error('terminal still starting')
|
||||
},
|
||||
{ shown: () => true }
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(shown.store.withdrawTerminalPane).not.toHaveBeenCalled()
|
||||
|
||||
const existing = storeAnswering('exists')
|
||||
await expect(
|
||||
startInAdmittedPane(existing.runtimeStore, SPLIT, undefined, async () => {
|
||||
throw new Error('spawn failed')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(existing.store.withdrawTerminalPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands a refusal to the start, which decides', async () => {
|
||||
const { runtimeStore } = storeAnswering('pane_not_found')
|
||||
const start = vi.fn(async () => 'as-today')
|
||||
|
||||
await expect(startInAdmittedPane(runtimeStore, SPLIT, undefined, start)).resolves.toEqual({
|
||||
outcome: 'pane_not_found',
|
||||
result: 'as-today'
|
||||
})
|
||||
})
|
||||
|
||||
it('starts without the pane after a failed write only when asked to', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const failing = storeAnswering(new Error('disk full'))
|
||||
const start = vi.fn(async () => 'as-today')
|
||||
|
||||
await expect(
|
||||
startInAdmittedPane(failing.runtimeStore, SPLIT, undefined, start)
|
||||
).rejects.toThrow('disk full')
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
startInAdmittedPane(failing.runtimeStore, SPLIT, undefined, start, {
|
||||
startIfWriteFails: true
|
||||
})
|
||||
).resolves.toEqual({ outcome: undefined, result: 'as-today' })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import type { TerminalPaneAdmission } from '../persistence/terminal-topology/terminal-pane-admission'
|
||||
import type {
|
||||
TerminalPaneAdmission,
|
||||
TerminalPaneAdmissionOutcome
|
||||
} from '../persistence/terminal-topology/terminal-pane-admission'
|
||||
import type { RuntimePtyController } from './runtime-pty-controller-contract'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
@@ -48,38 +51,80 @@ export async function withdrawRuntimePane(
|
||||
store: RuntimeStore | null | undefined,
|
||||
args: AdmittedSpawnArgs
|
||||
): Promise<void> {
|
||||
await withdrawAdmittedPane(store, admissionFor(args), hostIdOf(args))
|
||||
}
|
||||
|
||||
/** Never the user's close: main stops no launch for it, unlike a window's closePane. */
|
||||
async function withdrawAdmittedPane(
|
||||
store: RuntimeStore | null | undefined,
|
||||
admission: TerminalPaneAdmission,
|
||||
hostId: string | undefined
|
||||
): Promise<void> {
|
||||
const leafId = admission.type === 'createTerminalTab' ? admission.leafId : admission.newLeafId
|
||||
await store?.withdrawTerminalPane?.(
|
||||
{ type: 'closePane', workspace: args.worktreeId, tabId: args.tabId, leafId: args.leafId },
|
||||
hostIdOf(args)
|
||||
{ type: 'closePane', workspace: admission.workspace, tabId: admission.tabId, leafId },
|
||||
hostId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a runtime-originated terminal (design 4.1): its pane, tab-bar entry and group are written
|
||||
* first, then the process starts and binds to that pane. A start that fails, or that attaches to a
|
||||
* session already running in another pane, takes back the pane it wrote.
|
||||
* Writes a pane before what fills it starts (design 4.1), then runs `start`. A start that fails
|
||||
* takes back a pane this wrote, unless `shown` says a client already holds it.
|
||||
*/
|
||||
export async function startInAdmittedPane<T>(
|
||||
store: RuntimeStore | null | undefined,
|
||||
admission: TerminalPaneAdmission,
|
||||
hostId: string | undefined,
|
||||
start: (outcome: TerminalPaneAdmissionOutcome | undefined) => Promise<T>,
|
||||
options: {
|
||||
shown?: () => boolean
|
||||
/** A thrown write is rolled back, so the start may go ahead without the pane. */
|
||||
startIfWriteFails?: true
|
||||
} = {}
|
||||
): Promise<{ outcome: TerminalPaneAdmissionOutcome | undefined; result: T }> {
|
||||
let outcome: TerminalPaneAdmissionOutcome | undefined
|
||||
try {
|
||||
outcome = await store?.admitTerminalPane?.(admission, hostId)
|
||||
} catch (error) {
|
||||
if (!options.startIfWriteFails) {
|
||||
throw error
|
||||
}
|
||||
console.warn('[layout-owner] pane not written before its start:', error)
|
||||
}
|
||||
try {
|
||||
return { outcome, result: await start(outcome) }
|
||||
} catch (error) {
|
||||
if (outcome === 'admitted' && !options.shown?.()) {
|
||||
await withdrawAdmittedPane(store, admission, hostId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a runtime-originated terminal: its pane, tab-bar entry and group are written first, then
|
||||
* the process starts and binds to that pane. A start that fails, or that attaches to a session
|
||||
* already running in another pane, takes back the pane it wrote.
|
||||
*/
|
||||
export async function spawnInAdmittedPane(
|
||||
store: RuntimeStore | null | undefined,
|
||||
controller: Pick<RuntimePtyController, 'spawn'>,
|
||||
args: AdmittedSpawnArgs
|
||||
): ReturnType<Spawn> {
|
||||
const outcome = await store?.admitTerminalPane?.(admissionFor(args), hostIdOf(args))
|
||||
if (outcome !== undefined && outcome !== 'admitted' && outcome !== 'exists') {
|
||||
// Only a split is refused: its parent pane left the layout.
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
const { groupId: _groupId, ...spawnArgs } = args
|
||||
let result: Awaited<ReturnType<Spawn>>
|
||||
try {
|
||||
// SAFETY: Callers require spawn; a lost capability during admission keeps the original failure.
|
||||
result = await controller.spawn!(spawnArgs)
|
||||
} catch (error) {
|
||||
if (outcome === 'admitted') {
|
||||
await withdrawRuntimePane(store, args)
|
||||
const { outcome, result } = await startInAdmittedPane(
|
||||
store,
|
||||
admissionFor(args),
|
||||
hostIdOf(args),
|
||||
async (admitted) => {
|
||||
if (admitted !== undefined && admitted !== 'admitted' && admitted !== 'exists') {
|
||||
// Only a split is refused: its parent pane left the layout.
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
// SAFETY: Callers require spawn; a lost capability during admission keeps the original failure.
|
||||
return controller.spawn!(spawnArgs)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner
|
||||
const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId)
|
||||
if (outcome === 'admitted' && elsewhere) {
|
||||
|
||||
@@ -1,85 +1,116 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WindowLayoutCommand } from '../../runtime-layout-apply'
|
||||
import { splitWindowLeafThroughLayoutOwner } from './terminal-split-layout-owner'
|
||||
import { OrcaRuntimeService, makePaneKey } from '../../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal
|
||||
} from '../../orca-runtime-test-fixtures.spec'
|
||||
import { createMobileCreateTestNotifier } from '../../orca-runtime-test-scenario-builders.spec'
|
||||
import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree'
|
||||
|
||||
const LEAF = { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1' }
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
function deps(
|
||||
reply: LayoutApplyReply | Error,
|
||||
mount: (leafId: string) => Promise<string>,
|
||||
mounted = false
|
||||
) {
|
||||
const TAB_ID = 'host-tab'
|
||||
|
||||
function graph(leafIds: string[], unboundLeafIds: string[] = []) {
|
||||
return {
|
||||
apply: vi.fn(async (_command: WindowLayoutCommand): Promise<LayoutApplyReply> => {
|
||||
if (reply instanceof Error) {
|
||||
throw reply
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'shell',
|
||||
activeLeafId: leafIds[0]!,
|
||||
layout: null
|
||||
}
|
||||
return reply
|
||||
}),
|
||||
mintLeafId: () => 'minted-leaf',
|
||||
mount: vi.fn(mount),
|
||||
isMounted: () => mounted
|
||||
],
|
||||
leaves: [...leafIds, ...unboundLeafIds].map((leafId, index) => ({
|
||||
tabId: TAB_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: index + 1,
|
||||
ptyId: index < leafIds.length ? `pty-${index}` : null,
|
||||
paneTitle: null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
describe('splitWindowLeafThroughLayoutOwner', () => {
|
||||
it('writes the split in main first, then has the window mount main’s pane', async () => {
|
||||
const d = deps({ ok: true, result: { leafId: 'main-leaf' } }, async () => 'term_new')
|
||||
describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
it('writes the new pane in main before the window mounts it, and takes back one never mounted', async () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const notifier = createMobileCreateTestNotifier(vi.fn())
|
||||
const splitTerminal = vi.mocked(notifier.splitTerminal)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setNotifier(notifier)
|
||||
runtime.syncWindowGraph(1, graph([HEADLESS_LEAF_ID]))
|
||||
const handle = runtime.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))
|
||||
const leafIds = () =>
|
||||
collectLayoutLeafIdsInOrder(getSession().terminalLayoutsByTabId[TAB_ID]!.root!)
|
||||
|
||||
const split = await splitWindowLeafThroughLayoutOwner(LEAF, 'vertical', d)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle!, { direction: 'vertical' })
|
||||
const rejected = expect(split).rejects.toThrow('Timed out waiting for split pane handle')
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
|
||||
// Main holds the pane before the window has mounted it.
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId])
|
||||
|
||||
expect(d.apply).toHaveBeenCalledWith({
|
||||
type: 'splitPane',
|
||||
workspace: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
direction: 'vertical'
|
||||
})
|
||||
expect(d.mount).toHaveBeenCalledWith('main-leaf')
|
||||
expect(split).toEqual({ handle: 'term_new', leafId: 'main-leaf' })
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
await rejected
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['refuses', { ok: false, code: 'tab_not_found' } as const],
|
||||
['throws', new Error('layout_store_unavailable')]
|
||||
])('splits as today under a fresh id when main %s', async (_name, reply) => {
|
||||
const d = deps(reply, async () => 'term_new')
|
||||
|
||||
const split = await splitWindowLeafThroughLayoutOwner(LEAF, 'horizontal', d)
|
||||
|
||||
expect(d.mount).toHaveBeenCalledWith('minted-leaf')
|
||||
expect(split.leafId).toBe('minted-leaf')
|
||||
expect(d.apply).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('takes main’s pane back when the window never mounts it', async () => {
|
||||
const d = deps({ ok: true, result: { leafId: 'main-leaf' } }, async () => {
|
||||
throw new Error('timeout')
|
||||
})
|
||||
|
||||
await expect(splitWindowLeafThroughLayoutOwner(LEAF, 'vertical', d)).rejects.toThrow('timeout')
|
||||
|
||||
expect(d.apply).toHaveBeenLastCalledWith({
|
||||
type: 'closePane',
|
||||
workspace: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'main-leaf',
|
||||
automatic: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps main’s pane when the window mounted it but its terminal is still starting', async () => {
|
||||
const d = deps(
|
||||
{ ok: true, result: { leafId: 'main-leaf' } },
|
||||
async () => {
|
||||
throw new Error('timeout')
|
||||
},
|
||||
true
|
||||
['holds the pane while its terminal is still starting', 'graph'],
|
||||
['recorded the pane before its graph says so', 'record']
|
||||
] as const)('keeps a pane main awaited when the window %s', async (_, shownBy) => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const notifier = createMobileCreateTestNotifier(vi.fn())
|
||||
const splitTerminal = vi.mocked(notifier.splitTerminal)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setNotifier(notifier)
|
||||
runtime.syncWindowGraph(1, graph([HEADLESS_LEAF_ID]))
|
||||
const handle = runtime.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))
|
||||
|
||||
await expect(splitWindowLeafThroughLayoutOwner(LEAF, 'vertical', d)).rejects.toThrow('timeout')
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const split = runtime.splitTerminal(handle!, { direction: 'vertical' })
|
||||
const rejected = expect(split).rejects.toThrow('Timed out waiting for split pane handle')
|
||||
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
|
||||
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
|
||||
if (shownBy === 'graph') {
|
||||
// The window's session publication lists the new pane; this graph carries none.
|
||||
Reflect.set(runtime, 'isMobileSessionSurfaceMembershipAllowed', () => true)
|
||||
runtime.syncWindowGraph(1, graph([HEADLESS_LEAF_ID], [newLeafId]))
|
||||
} else {
|
||||
// This fixture store has no layout.apply; main notes the record before applying it.
|
||||
await runtime
|
||||
.applyLayoutCommand({
|
||||
type: 'splitPane',
|
||||
workspace: TEST_WORKTREE_ID,
|
||||
tabId: TAB_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
direction: 'vertical',
|
||||
newLeafId
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
expect(d.apply).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
await rejected
|
||||
expect(
|
||||
collectLayoutLeafIdsInOrder(getSession().terminalLayoutsByTabId[TAB_ID]!.root!)
|
||||
).toEqual([HEADLESS_LEAF_ID, newLeafId])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WindowLayoutCommand } from '../../runtime-layout-apply'
|
||||
|
||||
type SplitLeaf = { worktreeId: string; tabId: string; leafId: string }
|
||||
|
||||
/**
|
||||
* A split of a pane the window draws, in a layout-owner build: main writes the new pane first
|
||||
* and the window mounts that pane. Main's copy lacking the tab, or refusing, splits as today
|
||||
* under a fresh id. A pane the window never mounted is taken back; one still binding its
|
||||
* terminal (a slow SSH start) stays.
|
||||
*/
|
||||
export async function splitWindowLeafThroughLayoutOwner(
|
||||
leaf: SplitLeaf,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
deps: {
|
||||
apply: (command: WindowLayoutCommand) => Promise<LayoutApplyReply>
|
||||
mintLeafId: () => string
|
||||
/** Asks the window to mount the pane and waits for its terminal; the new pane's handle. */
|
||||
mount: (newLeafId: string) => Promise<string>
|
||||
/** Whether the window's graph holds the pane, bound or not. */
|
||||
isMounted: (newLeafId: string) => boolean
|
||||
}
|
||||
): Promise<{ handle: string; leafId: string }> {
|
||||
const { worktreeId: workspace, tabId, leafId } = leaf
|
||||
let reply: LayoutApplyReply | null = null
|
||||
try {
|
||||
reply = await deps.apply({
|
||||
type: 'splitPane',
|
||||
workspace,
|
||||
tabId,
|
||||
leafId,
|
||||
direction
|
||||
})
|
||||
} catch (error) {
|
||||
// A thrown write is rolled back; the window splits as today.
|
||||
console.warn('[layout-owner] runtime split failed:', error)
|
||||
}
|
||||
const mainLeafId = reply?.ok ? reply.result.leafId : undefined
|
||||
const newLeafId = mainLeafId ?? deps.mintLeafId()
|
||||
try {
|
||||
return { handle: await deps.mount(newLeafId), leafId: newLeafId }
|
||||
} catch (error) {
|
||||
if (mainLeafId && !deps.isMounted(mainLeafId)) {
|
||||
await deps
|
||||
.apply({ type: 'closePane', workspace, tabId, leafId: mainLeafId, automatic: true })
|
||||
.catch((withdrawError: unknown) =>
|
||||
console.warn('[layout-owner] unmounted split not withdrawn:', withdrawError)
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,28 @@ import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from '../../session-tabs/e
|
||||
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
|
||||
import type { RuntimeTerminalSplit } from '../../../../shared/runtime-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import type { WindowLayoutCommand } from '../../runtime-layout-apply'
|
||||
import { splitWindowLeafThroughLayoutOwner } from './terminal-split-layout-owner'
|
||||
import { startInAdmittedPane } from '../../runtime-pane-admission'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyClosedTabPtys {
|
||||
/** Pane id of each split main wrote and awaits → whether the window already recorded it. */
|
||||
private readonly awaitedSplitLeafIds = new Map<string, boolean>()
|
||||
|
||||
/** The window recorded a split it mounted: one main awaits counts as shown before its graph says so. */
|
||||
protected noteWindowMountedSplit(leafId: string): void {
|
||||
if (this.awaitedSplitLeafIds.has(leafId)) {
|
||||
this.awaitedSplitLeafIds.set(leafId, true)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract splitPtyBackedTerminal(
|
||||
pty: RuntimePtyWorktreeRecord,
|
||||
opts?: TerminalSplitOptions,
|
||||
createdWorktree?: Worktree
|
||||
): Promise<RuntimeTerminalSplit>
|
||||
|
||||
abstract applyLayoutCommand(command: WindowLayoutCommand): Promise<LayoutApplyReply>
|
||||
|
||||
protected abstract waitForLeafInTab(
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
@@ -49,11 +56,25 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
const direction = opts.direction ?? 'horizontal'
|
||||
// Switch point: main writes the split first; the window then mounts main's pane.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
const split = await splitWindowLeafThroughLayoutOwner(leaf, direction, {
|
||||
apply: (command) => this.applyLayoutCommand(command),
|
||||
mintLeafId: randomUUID,
|
||||
mount: (newLeafId) => {
|
||||
const [hostId] = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId)
|
||||
: []
|
||||
if (hostId && parseExecutionHostId(hostId)?.kind !== 'runtime') {
|
||||
const newLeafId = randomUUID()
|
||||
this.awaitedSplitLeafIds.set(newLeafId, false)
|
||||
const { result: newHandle } = await startInAdmittedPane(
|
||||
this.store,
|
||||
{
|
||||
type: 'splitPane',
|
||||
workspace: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
direction,
|
||||
newLeafId
|
||||
},
|
||||
hostId,
|
||||
// A refused or failed write splits as today; the window records the pane it mounts.
|
||||
() => {
|
||||
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
|
||||
direction,
|
||||
command: opts.command,
|
||||
@@ -64,13 +85,19 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
|
||||
})
|
||||
return this.waitForLeafInTab(leaf.tabId, newLeafId)
|
||||
},
|
||||
isMounted: (newLeafId) => this.leaves.has(this.getLeafKey(leaf.tabId, newLeafId))
|
||||
})
|
||||
{
|
||||
// A pane the window holds but whose terminal is still starting (a slow SSH start) stays.
|
||||
shown: () =>
|
||||
this.awaitedSplitLeafIds.get(newLeafId) === true ||
|
||||
this.leaves.has(this.getLeafKey(leaf.tabId, newLeafId)),
|
||||
startIfWriteFails: true
|
||||
}
|
||||
).finally(() => this.awaitedSplitLeafIds.delete(newLeafId))
|
||||
return {
|
||||
handle: split.handle,
|
||||
handle: newHandle,
|
||||
tabId: leaf.tabId,
|
||||
paneRuntimeId: leaf.paneRuntimeId,
|
||||
leafId: split.leafId
|
||||
leafId: newLeafId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export function noteArmsHibernatedPaneWake(record: SleepingAgentSessionRecord):
|
||||
)
|
||||
}
|
||||
|
||||
/** A tab's sole pane keeps a dead terminal it freshly spawned and the user never typed into. */
|
||||
function keepsUntouchedFreshPane(session: ConnectPanePtySession, ptyId: string): boolean {
|
||||
return session.spawnedFreshPtyId === ptyId && !Number.isFinite(session.lastTerminalInputAt)
|
||||
}
|
||||
|
||||
/** PTY exit handling, hibernated-pane wake targets, and post-exit focus transfer. */
|
||||
export function installPtyExitHibernate(session: ConnectPanePtySession): void {
|
||||
type ProcessExitState = {
|
||||
@@ -323,7 +328,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void {
|
||||
// for this ptyId — reattach/coldRestore skip it) that the user never typed
|
||||
// into, so a reattached-dead session or an explicit `exit` still tears
|
||||
// down as before.
|
||||
if (session.spawnedFreshPtyId === ptyId && !Number.isFinite(session.lastTerminalInputAt)) {
|
||||
if (keepsUntouchedFreshPane(session, ptyId)) {
|
||||
return
|
||||
}
|
||||
session.deps.onPtyExitRef.current(ptyId, exitCode)
|
||||
@@ -354,14 +359,9 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void {
|
||||
manager: session.manager,
|
||||
paneId: session.pane.id,
|
||||
closeTab: () => {
|
||||
// The sole-pane rule above: a fresh terminal the user never typed into keeps its pane.
|
||||
if (
|
||||
session.spawnedFreshPtyId === ptyId &&
|
||||
!Number.isFinite(session.lastTerminalInputAt)
|
||||
) {
|
||||
return
|
||||
if (!keepsUntouchedFreshPane(session, ptyId)) {
|
||||
session.deps.onPtyExitRef.current(ptyId, exitCode)
|
||||
}
|
||||
session.deps.onPtyExitRef.current(ptyId, exitCode)
|
||||
}
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -24,15 +24,29 @@ export function recordingBootstrapSplits(
|
||||
const sourceLeafId = manager.getLeafId(paneId)
|
||||
const created = manager.splitPane(paneId, direction, options)
|
||||
if (created && sourceLeafId) {
|
||||
recordLayoutApply(useAppStore.getState(), worktreeId, {
|
||||
type: 'splitPane',
|
||||
tabId,
|
||||
leafId: sourceLeafId,
|
||||
direction,
|
||||
newLeafId: created.leafId
|
||||
})
|
||||
recordDrawnPaneSplit(worktreeId, tabId, sourceLeafId, direction, created.leafId)
|
||||
}
|
||||
return created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a split the window drew under its pane id. Main finds the pane it already holds and
|
||||
* changes nothing, or writes it again (one main took back while the window mounted it late).
|
||||
*/
|
||||
export function recordDrawnPaneSplit(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
sourceLeafId: string,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
newLeafId: string
|
||||
): void {
|
||||
recordLayoutApply(useAppStore.getState(), worktreeId, {
|
||||
type: 'splitPane',
|
||||
tabId,
|
||||
leafId: sourceLeafId,
|
||||
direction,
|
||||
newLeafId
|
||||
})
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import { installTerminalPaneMountEvents } from './terminal-pane-mount-events'
|
||||
import {
|
||||
_resetTerminalPaneSplitRequestRoutingForTests,
|
||||
dispatchTerminalPaneSplitRequest
|
||||
} from './terminal-pane-split-request-routing'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
const recordLayoutApply = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply }))
|
||||
const storeState = vi.hoisted(() => ({ recordFeatureInteraction: () => {} }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } }))
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() }))
|
||||
|
||||
const SOURCE = '11111111-1111-4111-8111-111111111111'
|
||||
const NEW = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function mountedTab(): { manager: PaneManager; splitPane: ReturnType<typeof vi.fn> } {
|
||||
const leafById = new Map<number, string>([[1, SOURCE]])
|
||||
const splitPane = vi.fn((_paneId: number, _direction: string, opts?: { leafId?: string }) => {
|
||||
const leafId = opts?.leafId ?? 'window-minted'
|
||||
leafById.set(2, leafId)
|
||||
return { id: 2, leafId }
|
||||
})
|
||||
const manager = {
|
||||
splitPane,
|
||||
getLeafId: (paneId: number) => leafById.get(paneId) ?? null,
|
||||
getNumericIdForLeaf: (leafId: string) =>
|
||||
[...leafById].find(([, id]) => id === leafId)?.[0] ?? null
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the split handler calls only these manager methods.
|
||||
return { manager: manager as unknown as PaneManager, splitPane }
|
||||
}
|
||||
|
||||
function install(manager: PaneManager): () => void {
|
||||
return installTerminalPaneMountEvents({
|
||||
manager,
|
||||
deps: {
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
isActive: true,
|
||||
managerRef: { current: manager },
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
syncCanExpandState: vi.fn(),
|
||||
queueResizeAll: vi.fn()
|
||||
},
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a split with no startup command reads no pty deps.
|
||||
ptyDeps: {} as PtyConnectionDeps
|
||||
})
|
||||
}
|
||||
|
||||
describe('a split main asked for, mounted in a layout-owner build', () => {
|
||||
afterEach(() => {
|
||||
recordLayoutApply.mockReset()
|
||||
_resetTerminalPaneSplitRequestRoutingForTests()
|
||||
})
|
||||
|
||||
it('records the mounted pane in main, so a pane main took back meanwhile returns', () => {
|
||||
const { manager, splitPane } = mountedTab()
|
||||
const uninstall = install(manager)
|
||||
|
||||
dispatchTerminalPaneSplitRequest({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
paneRuntimeId: 1,
|
||||
sourceLeafId: SOURCE,
|
||||
direction: 'vertical',
|
||||
newLeafId: NEW
|
||||
})
|
||||
|
||||
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { leafId: NEW })
|
||||
expect(recordLayoutApply).toHaveBeenCalledWith(storeState, 'wt-1', {
|
||||
type: 'splitPane',
|
||||
tabId: 'tab-1',
|
||||
leafId: SOURCE,
|
||||
direction: 'vertical',
|
||||
newLeafId: NEW
|
||||
})
|
||||
uninstall()
|
||||
})
|
||||
|
||||
it('records nothing for a split that names no pane id', () => {
|
||||
const { manager } = mountedTab()
|
||||
const uninstall = install(manager)
|
||||
|
||||
dispatchTerminalPaneSplitRequest({
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
paneRuntimeId: 1,
|
||||
sourceLeafId: SOURCE,
|
||||
direction: 'horizontal'
|
||||
})
|
||||
|
||||
expect(recordLayoutApply).not.toHaveBeenCalled()
|
||||
uninstall()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
resolveTerminalPaneSplitSourceId
|
||||
} from './terminal-pane-split-request-routing'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import { recordDrawnPaneSplit } from './terminal-pane-bootstrap-split-record'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
|
||||
export function installTerminalPaneMountEvents(args: {
|
||||
manager: PaneManager
|
||||
@@ -47,6 +49,12 @@ export function installTerminalPaneMountEvents(args: {
|
||||
if (sourcePaneId < 0) {
|
||||
return
|
||||
}
|
||||
const recordMainSplitMounted = (leafId: string | undefined): void => {
|
||||
const sourceLeafId = mgr.getLeafId(sourcePaneId)
|
||||
if (leafId && leafId === detail.newLeafId && sourceLeafId) {
|
||||
recordDrawnPaneSplit(deps.worktreeId, deps.tabId, sourceLeafId, detail.direction, leafId)
|
||||
}
|
||||
}
|
||||
const splitOptions = {
|
||||
...(detail.newLeafId ? { leafId: detail.newLeafId } : {}),
|
||||
...(detail.ptyId ? { ptyId: detail.ptyId } : {})
|
||||
@@ -55,12 +63,19 @@ export function installTerminalPaneMountEvents(args: {
|
||||
const createdPane = splitPaneWithOneShotStartup(ptyDeps, { command: detail.command }, () =>
|
||||
mgr.splitPane(sourcePaneId, detail.direction, splitOptions)
|
||||
)
|
||||
// Switch point: main may have taken back a pane the window mounted late; record it again.
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordMainSplitMounted(createdPane?.leafId)
|
||||
}
|
||||
recordRuntimeCreatedTerminalPaneSplit(createdPane, {
|
||||
source: detail.telemetrySource ?? 'command',
|
||||
direction: detail.direction
|
||||
})
|
||||
} else {
|
||||
const createdPane = mgr.splitPane(sourcePaneId, detail.direction, splitOptions)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
recordMainSplitMounted(createdPane?.leafId)
|
||||
}
|
||||
const telemetrySuppressed = createdPane
|
||||
? consumePendingWebRuntimeSplitMirrorTelemetry(detail.sourcePtyId, detail.direction)
|
||||
: false
|
||||
|
||||
Reference in New Issue
Block a user