mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Save runtime-started tabs in the tab bar before their terminal starts (#27083)
* Write runtime-started panes with their tab bar entry and group before they spawn Co-Authored-By: Claude <noreply@anthropic.com> * List the headless reorder's row-order finding and mark the headless group writer for the switch Co-Authored-By: Claude <noreply@anthropic.com> * Admit runtime-started tabs into the targeted or focused tab group A phone, CLI or orchestration create on a headless host with two tab groups was written into the first group, so after a restart the tab moved groups. The phone's targetGroupId, else the host's focused group, now reaches the pane admission. Co-Authored-By: Claude <noreply@anthropic.com> * Admit an untargeted runtime-started tab into the first tab group Defaulting to the host's focused group moved a CLI-created tab to the right group after a restart and in the desktop window, while main and the live view keep it in the first group. Only the phone's explicit targetGroupId now picks the group. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -159,7 +159,7 @@ describe('registerPtyHandlers', () => {
|
||||
leafId,
|
||||
ptyId: expect.any(String),
|
||||
incarnationId: expect.any(String),
|
||||
hostAdmittedMembership: true,
|
||||
mayCreate: false,
|
||||
origin: 'spawn'
|
||||
})
|
||||
})
|
||||
@@ -519,7 +519,7 @@ describe('registerPtyHandlers', () => {
|
||||
leafId,
|
||||
ptyId: 'pty-shared',
|
||||
startupCwd: '/tmp',
|
||||
hostAdmittedMembership: true,
|
||||
mayCreate: false,
|
||||
origin: 'spawn'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -542,7 +542,7 @@ describe('registerPtyHandlers', () => {
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
ptyId: 'ssh:ssh-1@@relay-pty',
|
||||
hostAdmittedMembership: true,
|
||||
mayCreate: false,
|
||||
origin: 'spawn'
|
||||
},
|
||||
'ssh:ssh-1'
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('registerPtyHandlers', () => {
|
||||
tabId: 'tab-remote',
|
||||
leafId,
|
||||
ptyId: 'ssh:ssh-reattach-ok@@relay-pty',
|
||||
hostAdmittedMembership: true,
|
||||
mayCreate: false,
|
||||
origin: 'reattach'
|
||||
},
|
||||
'ssh:ssh-reattach-ok'
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('pty spawn placement threading', () => {
|
||||
leafId: LEAF,
|
||||
ptyId: expect.any(String),
|
||||
incarnationId: expect.any(String),
|
||||
hostAdmittedMembership: true,
|
||||
mayCreate: false,
|
||||
placement: SPLIT,
|
||||
origin: 'spawn'
|
||||
})
|
||||
|
||||
@@ -150,7 +150,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
tabId,
|
||||
leafId,
|
||||
ptyId: ctx.result.id,
|
||||
hostAdmittedMembership: true,
|
||||
// The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile.
|
||||
mayCreate: false,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
|
||||
...(expectedSourceBinding ? { expectedSourceBinding } : {}),
|
||||
|
||||
+110
-21
@@ -47,7 +47,23 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st
|
||||
return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id)
|
||||
}
|
||||
|
||||
describe('host-admitted terminal membership survives a stale renderer replay', () => {
|
||||
/** `orca terminal create`: the runtime writes the pane, then binds the terminal it started. */
|
||||
async function startRuntimePane(
|
||||
store: Awaited<ReturnType<typeof createStore>>,
|
||||
pane: { worktreeId: string; tabId: string; leafId: string; ptyId: string; incarnationId?: string }
|
||||
): Promise<boolean> {
|
||||
expect(
|
||||
await store.admitTerminalPane({
|
||||
type: 'createTerminalTab',
|
||||
workspace: pane.worktreeId,
|
||||
tabId: pane.tabId,
|
||||
leafId: pane.leafId
|
||||
})
|
||||
).toBe('admitted')
|
||||
return store.persistPtyBinding({ ...pane, mayCreate: false })
|
||||
}
|
||||
|
||||
describe('runtime-admitted terminal membership survives a stale renderer replay', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-'))
|
||||
})
|
||||
@@ -57,21 +73,26 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => {
|
||||
it('keeps the first runtime-admitted tab when the renderer replays its pre-create tab list', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
|
||||
// `orca terminal create`: the host mints a tab the renderer has never seen.
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
await startRuntimePane(store, {
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'host-tab',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'host-pty',
|
||||
hostAdmittedMembership: true
|
||||
ptyId: 'host-pty'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
|
||||
const session = store.getWorkspaceSession()
|
||||
expect(persistedTabIds(session, WORKTREE)).toContain('host-tab')
|
||||
// Written with its tab-bar entry and group, so no view reads a row the tab bar lacks.
|
||||
expect(session.unifiedTabs?.[WORKTREE]?.map((tab) => tab.entityId)).toContain('host-tab')
|
||||
expect(session.tabGroups?.[WORKTREE]?.flatMap((group) => group.tabOrder)).toContain('host-tab')
|
||||
expect(session.terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId).toEqual({
|
||||
[TEST_LEAF_2]: 'host-pty'
|
||||
})
|
||||
|
||||
// The renderer's debounced writer flushes a snapshot taken before the create.
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
@@ -79,16 +100,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
|
||||
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
|
||||
})
|
||||
|
||||
it('keeps a host-admitted tab in a second worktree of the same repo', async () => {
|
||||
it('keeps a runtime-admitted tab in a second worktree of the same repo', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
|
||||
await store.persistPtyBinding({
|
||||
await startRuntimePane(store, {
|
||||
worktreeId: OTHER_WORKTREE,
|
||||
tabId: 'host-tab-other',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'host-pty-other',
|
||||
hostAdmittedMembership: true
|
||||
ptyId: 'host-pty-other'
|
||||
})
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
|
||||
@@ -100,12 +120,11 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
await startRuntimePane(store, {
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'host-tab',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'host-pty',
|
||||
hostAdmittedMembership: true
|
||||
ptyId: 'host-pty'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(
|
||||
@@ -118,9 +137,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
|
||||
)
|
||||
})
|
||||
|
||||
// Polarity: without the flag the renderer still owns membership, so a renderer
|
||||
// spawn racing its own writer must not freeze the tab list.
|
||||
it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => {
|
||||
// Polarity: a renderer spawn racing its own writer must not freeze the tab list.
|
||||
it('leaves renderer-owned membership alone when the runtime did not admit the pane', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
|
||||
@@ -138,16 +156,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
|
||||
// Closing must still work afterwards. Closes are host-driven: the retirement is
|
||||
// computed from the store's own session (see stageTerminalSurfaceRetirements),
|
||||
// which is what outranks the fence this create just raised.
|
||||
it('still lets the authoritative retirement path close the host-admitted tab', async () => {
|
||||
it('still lets the authoritative retirement path close the runtime-admitted tab', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
await store.persistPtyBinding({
|
||||
await startRuntimePane(store, {
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'host-tab',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'host-pty',
|
||||
incarnationId: 'host-incarnation',
|
||||
hostAdmittedMembership: true
|
||||
incarnationId: 'host-incarnation'
|
||||
})
|
||||
|
||||
store.setWorkspaceSession(
|
||||
@@ -164,4 +181,76 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
|
||||
|
||||
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
|
||||
})
|
||||
|
||||
it('refuses to bind a pane closed while its terminal started, and mints no tab for it', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
expect(
|
||||
await store.admitTerminalPane({
|
||||
type: 'createTerminalTab',
|
||||
workspace: WORKTREE,
|
||||
tabId: 'host-tab',
|
||||
leafId: TEST_LEAF_2
|
||||
})
|
||||
).toBe('admitted')
|
||||
await store.withdrawTerminalPane({
|
||||
type: 'closePane',
|
||||
workspace: WORKTREE,
|
||||
tabId: 'host-tab',
|
||||
leafId: TEST_LEAF_2
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'host-tab',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'host-pty',
|
||||
mayCreate: false
|
||||
})
|
||||
).toBe(false)
|
||||
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
|
||||
})
|
||||
|
||||
it('refuses to bind a split pane that moved to another tab while its terminal started', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorkspaceSession(rendererSession())
|
||||
expect(
|
||||
await store.admitTerminalPane({
|
||||
type: 'splitPane',
|
||||
workspace: WORKTREE,
|
||||
tabId: 'renderer-tab',
|
||||
leafId: TEST_LEAF_1,
|
||||
direction: 'vertical',
|
||||
newLeafId: TEST_LEAF_2
|
||||
})
|
||||
).toBe('admitted')
|
||||
await expect(
|
||||
store.moveTerminalLeafToNewTab({
|
||||
worktreeId: WORKTREE,
|
||||
sourceTabId: 'renderer-tab',
|
||||
targetTabId: 'moved-tab',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: null
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'moved' })
|
||||
|
||||
expect(
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'renderer-tab',
|
||||
leafId: TEST_LEAF_2,
|
||||
ptyId: 'late-pty',
|
||||
mayCreate: false
|
||||
})
|
||||
).toBe(false)
|
||||
const session = store.getWorkspaceSession()
|
||||
expect(session.terminalLayoutsByTabId['renderer-tab']?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: TEST_LEAF_1
|
||||
})
|
||||
expect(
|
||||
session.terminalLayoutsByTabId['moved-tab']?.ptyIdsByLeafId?.[TEST_LEAF_2]
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ProfileStateDomainReplacement
|
||||
} from './profile-state-authority'
|
||||
import { Store } from './store'
|
||||
import { toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
@@ -147,3 +148,20 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) {
|
||||
}
|
||||
return { store, authority, readState }
|
||||
}
|
||||
|
||||
/** The runtime writes a pane before it spawns; a runtime spawn commit binds only that pane. */
|
||||
export async function admitRuntimeSpawnPane(
|
||||
store: Store,
|
||||
pane: { worktreeId: string; tabId: string; leafId: string },
|
||||
connectionId?: string | null
|
||||
): Promise<void> {
|
||||
await store.admitTerminalPane(
|
||||
{
|
||||
type: 'createTerminalTab',
|
||||
workspace: pane.worktreeId,
|
||||
tabId: pane.tabId,
|
||||
leafId: pane.leafId
|
||||
},
|
||||
connectionId ? toSshExecutionHostId(connectionId) : undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,20 @@ import type {
|
||||
TerminalLeafMoveRequest,
|
||||
TerminalLeafMoveResult
|
||||
} from '../../../shared/terminal-leaf-move'
|
||||
import { moveLeaf } from '../terminal-topology/terminal-topology-commit'
|
||||
import {
|
||||
admitPane,
|
||||
moveLeaf,
|
||||
withdrawPane,
|
||||
type TerminalTopologyCommitContext
|
||||
} from '../terminal-topology/terminal-topology-commit'
|
||||
import type {
|
||||
TerminalPaneAdmission,
|
||||
TerminalPaneAdmissionOutcome
|
||||
} from '../terminal-topology/terminal-pane-admission'
|
||||
import type {
|
||||
CommandOf,
|
||||
LayoutRefusalCode
|
||||
} from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants'
|
||||
|
||||
type PtyBindingPersistenceOperationsRuntime = Pick<
|
||||
@@ -46,8 +59,6 @@ export type PersistPtyBindingArgs = {
|
||||
startupCwd?: string
|
||||
expectedBinding?: { ptyId: string; incarnationId?: string }
|
||||
expectedSourceBinding?: PtyBindingSourceExpectation
|
||||
/** Set by host-initiated creates, which have no renderer session writer behind them. */
|
||||
hostAdmittedMembership?: boolean
|
||||
/**
|
||||
* Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer
|
||||
* and must be able to mint the surface it is binding. A reattach is the opposite: the pane
|
||||
@@ -217,16 +228,41 @@ export class PtyBindingPersistenceOperations {
|
||||
* the binding domain only for its runtime and partition access; the commit module owns the write.
|
||||
*/
|
||||
moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> {
|
||||
const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext]
|
||||
return runtime.runDurableMutation(
|
||||
moveLeaf(request, {
|
||||
state: runtime.state,
|
||||
hostIds: () => sessions.getWorkspaceSessionHostIds(),
|
||||
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
|
||||
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
|
||||
})
|
||||
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
|
||||
moveLeaf(request, topologyCommitContext(this))
|
||||
)
|
||||
}
|
||||
|
||||
/** Writes a runtime-started pane before its terminal starts; the commit module owns the write. */
|
||||
admitTerminalPane(
|
||||
admission: TerminalPaneAdmission,
|
||||
hostId?: string | null
|
||||
): Promise<TerminalPaneAdmissionOutcome> {
|
||||
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
|
||||
admitPane(resolveHostId(hostId), admission, topologyCommitContext(this))
|
||||
)
|
||||
}
|
||||
|
||||
withdrawTerminalPane(
|
||||
pane: CommandOf<'closePane'>,
|
||||
hostId?: string | null
|
||||
): Promise<LayoutRefusalCode | null> {
|
||||
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
|
||||
withdrawPane(resolveHostId(hostId), pane, topologyCommitContext(this))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function topologyCommitContext(
|
||||
owner: PtyBindingPersistenceOperations
|
||||
): TerminalTopologyCommitContext {
|
||||
const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext]
|
||||
return {
|
||||
state: runtime.state,
|
||||
hostIds: () => sessions.getWorkspaceSessionHostIds(),
|
||||
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
|
||||
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
|
||||
}
|
||||
}
|
||||
|
||||
function writePtyBinding(
|
||||
|
||||
@@ -64,8 +64,7 @@ const SCENARIOS: Scenario[] = [
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-new',
|
||||
incarnationId: 'inc-new',
|
||||
startupCwd: '/fixture/local/sub',
|
||||
hostAdmittedMembership: true
|
||||
startupCwd: '/fixture/local/sub'
|
||||
},
|
||||
placements: (leaf) => [
|
||||
[NEW_TAB, 'agrees'],
|
||||
@@ -110,8 +109,7 @@ const SCENARIOS: Scenario[] = [
|
||||
worktreeId: FOLDER_WORKTREE,
|
||||
tabId: 'tab-folder',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'pty-folder',
|
||||
hostAdmittedMembership: true
|
||||
ptyId: 'pty-folder'
|
||||
},
|
||||
placements: () => [
|
||||
[NEW_TAB, 'agrees'],
|
||||
|
||||
@@ -18,15 +18,12 @@ export function applyPtyBinding(
|
||||
const reconciledIncarnation =
|
||||
args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId
|
||||
let terminalMembershipChanged = false
|
||||
let hostAdmittedTabCreated = false
|
||||
const advanceTopologyFence = (): void => {
|
||||
const repoId = getRepoIdFromWorktreeId(bindingWorktreeId)
|
||||
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
|
||||
// Why: a split, or a host-admitted tab the renderer has never seen, is itself
|
||||
// the authority — with no fence the renderer's pre-create tab list replays
|
||||
// over it and the tab is lost even on the repo's first such change.
|
||||
const establishesMembershipAuthority =
|
||||
args.expectedSourceBinding !== undefined || hostAdmittedTabCreated
|
||||
// Why: a split is itself the authority — with no fence the renderer's pre-split
|
||||
// pane list replays over it and the pane is lost even on the repo's first split.
|
||||
const establishesMembershipAuthority = args.expectedSourceBinding !== undefined
|
||||
if (
|
||||
!reconciledIncarnation &&
|
||||
(!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority))
|
||||
@@ -62,7 +59,6 @@ export function applyPtyBinding(
|
||||
)
|
||||
} else {
|
||||
terminalMembershipChanged = true
|
||||
hostAdmittedTabCreated = args.hostAdmittedMembership === true
|
||||
// Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned.
|
||||
const nextTabs = [
|
||||
...(tabs ?? []),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fixture } from './profile-state-delayed-authority-fixture'
|
||||
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
|
||||
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
|
||||
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
|
||||
@@ -71,6 +71,7 @@ describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) =>
|
||||
})
|
||||
ctx.result = result
|
||||
ctx.metadataLeafId = binding.leafId
|
||||
await admitRuntimeSpawnPane(store, binding, connectionId)
|
||||
ctx.hostSessionBinding = { store, ...binding }
|
||||
shutdown = vi.spyOn(ctx.provider, 'shutdown')
|
||||
commit = () => commitRuntimePtySpawn(ctx)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { fixture } from './profile-state-delayed-authority-fixture'
|
||||
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
|
||||
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit'
|
||||
import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state'
|
||||
@@ -78,6 +78,7 @@ it.each(
|
||||
const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 })
|
||||
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
|
||||
ctx.stablePaneOwner = owner
|
||||
await admitRuntimeSpawnPane(store, binding, connectionId)
|
||||
ctx.hostSessionBinding = { store, ...binding }
|
||||
ctx.metadataLeafId = binding.leafId
|
||||
commit = () => commitRuntimePtySpawn(ctx)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { fixture } from './profile-state-delayed-authority-fixture'
|
||||
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
|
||||
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
|
||||
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
|
||||
@@ -62,6 +62,7 @@ it.each(['ipc', 'runtime'])(
|
||||
})
|
||||
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
|
||||
ctx.metadataLeafId = binding.leafId
|
||||
await admitRuntimeSpawnPane(store, binding)
|
||||
ctx.hostSessionBinding = { store, ...binding }
|
||||
commit = () => commitRuntimePtySpawn(ctx)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isTerminalSessionStorageCapacityFailure } from '../../../shared/terminal-session-state-save-failure'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { deferred, fixture } from './profile-state-delayed-authority-fixture'
|
||||
import { deferred, fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
|
||||
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
|
||||
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
|
||||
@@ -72,6 +72,7 @@ it.each(
|
||||
const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 })
|
||||
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
|
||||
ctx.metadataLeafId = binding.leafId
|
||||
await admitRuntimeSpawnPane(store, binding)
|
||||
ctx.hostSessionBinding = { store, ...binding }
|
||||
shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown)
|
||||
commit = () => commitRuntimePtySpawn(ctx)
|
||||
@@ -137,6 +138,7 @@ it.each(['ipc', 'runtime'])(
|
||||
const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 })
|
||||
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
|
||||
ctx.metadataLeafId = binding.leafId
|
||||
await admitRuntimeSpawnPane(store, binding)
|
||||
ctx.hostSessionBinding = { store, ...binding }
|
||||
commit = () => commitRuntimePtySpawn(ctx)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fixture } from './profile-state-delayed-authority-fixture'
|
||||
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
|
||||
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
|
||||
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
|
||||
@@ -56,6 +56,7 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => {
|
||||
const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 })
|
||||
ctx.result = result
|
||||
ctx.metadataLeafId = binding.leafId
|
||||
await admitRuntimeSpawnPane(store, binding)
|
||||
ctx.hostSessionBinding = { store, ...binding }
|
||||
commit = () => commitRuntimePtySpawn(ctx)
|
||||
}
|
||||
|
||||
@@ -38,8 +38,7 @@ export function createMinimalPersistedTerminalTab(args: {
|
||||
color: null,
|
||||
sortOrder: args.existingTabCount,
|
||||
createdAt: Date.now(),
|
||||
...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
|
||||
pendingActivationSpawn: true
|
||||
...(args.startupCwd ? { startupCwd: args.startupCwd } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ function moveLeafInPartition(
|
||||
const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId]
|
||||
const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId]))
|
||||
const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {})
|
||||
const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({
|
||||
const row = createMinimalPersistedTerminalTab({
|
||||
worktreeId,
|
||||
tabId: targetTabId,
|
||||
ptyId: ptyId ?? '',
|
||||
@@ -78,8 +78,6 @@ function moveLeafInPartition(
|
||||
const targetTab = {
|
||||
...row,
|
||||
ptyId,
|
||||
// A moved live pane reattaches; only an unbound one still spawns on activation.
|
||||
...(ptyId ? {} : { pendingActivationSpawn }),
|
||||
...(sourceTab.shellOverride ? { shellOverride: sourceTab.shellOverride } : {})
|
||||
}
|
||||
const nextTabs = tabs.map((tab) =>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// A runtime-started pane, written through the layout module before its terminal starts (design
|
||||
// 4.1): Loader, then the command, then the Serializer, so the partition is rewritten from one model.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { locatePane } from '../../../shared/workspace-layout/workspace-layout-command-steps'
|
||||
import type {
|
||||
CommandOf,
|
||||
LayoutCommand,
|
||||
LayoutRefusalCode
|
||||
} from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
|
||||
import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load'
|
||||
import type { LoadedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load-types'
|
||||
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model'
|
||||
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
|
||||
|
||||
/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */
|
||||
export type TerminalPaneAdmission =
|
||||
| (CommandOf<'createTerminalTab'> & { tabId: string; leafId: string })
|
||||
| (CommandOf<'splitPane'> & { newLeafId: string })
|
||||
|
||||
/** 'exists': the start attaches to a pane already in the layout, so nothing is written. */
|
||||
export type TerminalPaneAdmissionOutcome = 'admitted' | 'exists' | LayoutRefusalCode
|
||||
|
||||
/** The partition to write, or null when nothing changes. */
|
||||
export type PaneLayoutChange<T> = { value: T; session: WorkspaceSessionState | null }
|
||||
|
||||
export function admitPaneToSession(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState,
|
||||
admission: TerminalPaneAdmission
|
||||
): PaneLayoutChange<TerminalPaneAdmissionOutcome> {
|
||||
const leafId = admission.type === 'createTerminalTab' ? admission.leafId : admission.newLeafId
|
||||
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
|
||||
const workspace = loaded.layout.workspaces[admission.workspace]
|
||||
if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) {
|
||||
return { value: 'exists', session: null }
|
||||
}
|
||||
return applyToSession(loaded, admission, 'admitted')
|
||||
}
|
||||
|
||||
export function withdrawPaneFromSession(
|
||||
hostId: ExecutionHostId,
|
||||
session: WorkspaceSessionState,
|
||||
pane: CommandOf<'closePane'>
|
||||
): PaneLayoutChange<LayoutRefusalCode | null> {
|
||||
return applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane, null)
|
||||
}
|
||||
|
||||
function applyToSession<T>(
|
||||
loaded: LoadedWorkspaceLayout,
|
||||
command: LayoutCommand,
|
||||
committed: T
|
||||
): PaneLayoutChange<T | LayoutRefusalCode> {
|
||||
const applied = applyLayoutCommand(loaded.layout, command, {
|
||||
mintId: randomUUID,
|
||||
mintLeafId: randomUUID,
|
||||
now: Date.now
|
||||
})
|
||||
return applied.ok
|
||||
? { value: committed, session: saveWorkspaceLayout({ ...loaded, layout: applied.model }) }
|
||||
: { value: applied.code, session: null }
|
||||
}
|
||||
@@ -100,14 +100,24 @@ describe('an old client pane tree that predates a split or close', () => {
|
||||
|
||||
it('keeps a pane another client split since the tree was built', async () => {
|
||||
const store = await openWithPanes([TEST_LEAF_1])
|
||||
// `terminal.split` from the CLI: a host-created pane bound against its source pane.
|
||||
// `terminal.split` from the CLI: the runtime writes the pane, then binds it against its source.
|
||||
await expect(
|
||||
store.admitTerminalPane({
|
||||
type: 'splitPane',
|
||||
workspace: WT,
|
||||
tabId: 'tab-a',
|
||||
leafId: TEST_LEAF_1,
|
||||
direction: 'vertical',
|
||||
newLeafId: NEW_LEAF
|
||||
})
|
||||
).resolves.toBe('admitted')
|
||||
await expect(
|
||||
store.persistPtyBinding({
|
||||
worktreeId: WT,
|
||||
tabId: 'tab-a',
|
||||
leafId: NEW_LEAF,
|
||||
ptyId: 'pty-new',
|
||||
hostAdmittedMembership: true,
|
||||
mayCreate: false,
|
||||
expectedSourceBinding: { tabId: 'tab-a', leafId: TEST_LEAF_1, ptyId: 'pty-1' }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
@@ -5,6 +5,17 @@ import type {
|
||||
TerminalLeafMoveResult
|
||||
} from '../../../shared/terminal-leaf-move'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type {
|
||||
CommandOf,
|
||||
LayoutRefusalCode
|
||||
} from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import {
|
||||
admitPaneToSession,
|
||||
withdrawPaneFromSession,
|
||||
type PaneLayoutChange,
|
||||
type TerminalPaneAdmission,
|
||||
type TerminalPaneAdmissionOutcome
|
||||
} from './terminal-pane-admission'
|
||||
import { startSpan } from '../../observability/tracer'
|
||||
import {
|
||||
terminalSurfaceCloseMutation,
|
||||
@@ -18,7 +29,12 @@ import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
|
||||
// the pane move; the close transform still lives in runtime/ and other writers move here later.
|
||||
|
||||
/** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */
|
||||
type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf'
|
||||
type TerminalTopologyCommitKind =
|
||||
| 'close_leaf'
|
||||
| 'close_tab'
|
||||
| 'move_leaf'
|
||||
| 'admit_pane'
|
||||
| 'withdraw_pane'
|
||||
|
||||
export function closeLeafOrTab(
|
||||
commit: TerminalSurfaceCloseCommit
|
||||
@@ -43,6 +59,45 @@ export function moveLeaf(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the pane, its tab-bar entry and group before its terminal starts (design 4.1), through the
|
||||
* layout module.
|
||||
*/
|
||||
export function admitPane(
|
||||
hostId: ExecutionHostId,
|
||||
admission: TerminalPaneAdmission,
|
||||
context: TerminalTopologyCommitContext
|
||||
): () => DurableProfileStateMutation<TerminalPaneAdmissionOutcome> {
|
||||
return traced(
|
||||
'admit_pane',
|
||||
() =>
|
||||
commitPaneChange(
|
||||
hostId,
|
||||
admitPaneToSession(hostId, context.getSession(hostId), admission),
|
||||
context
|
||||
),
|
||||
(outcome) => (outcome === 'admitted' || outcome === 'exists' ? undefined : outcome)
|
||||
)
|
||||
}
|
||||
|
||||
/** Removes a pane `admitPane` wrote whose start then failed, so a failed create leaves no tab. */
|
||||
export function withdrawPane(
|
||||
hostId: ExecutionHostId,
|
||||
pane: CommandOf<'closePane'>,
|
||||
context: TerminalTopologyCommitContext
|
||||
): () => DurableProfileStateMutation<LayoutRefusalCode | null> {
|
||||
return traced(
|
||||
'withdraw_pane',
|
||||
() =>
|
||||
commitPaneChange(
|
||||
hostId,
|
||||
withdrawPaneFromSession(hostId, context.getSession(hostId), pane),
|
||||
context
|
||||
),
|
||||
(code) => code ?? undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `persistence.terminal-topology` span per commit, from admission to the in-memory write.
|
||||
* Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code.
|
||||
@@ -82,7 +137,7 @@ type TopologyState = Pick<
|
||||
'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases'
|
||||
>
|
||||
|
||||
type TerminalTopologyCommitContext = {
|
||||
export type TerminalTopologyCommitContext = {
|
||||
state: TopologyState
|
||||
hostIds: () => ExecutionHostId[]
|
||||
getSession: (hostId: ExecutionHostId) => WorkspaceSessionState
|
||||
@@ -147,3 +202,19 @@ function commitLeafMove(
|
||||
rollback: () => restores.forEach((restore) => restore())
|
||||
}
|
||||
}
|
||||
|
||||
function commitPaneChange<T>(
|
||||
hostId: ExecutionHostId,
|
||||
change: PaneLayoutChange<T>,
|
||||
context: TerminalTopologyCommitContext
|
||||
): DurableProfileStateMutation<T> {
|
||||
if (!change.session) {
|
||||
return { value: change.value, persist: false }
|
||||
}
|
||||
const restore = writeRestorable(
|
||||
() => context.getSession(hostId),
|
||||
(value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)),
|
||||
change.session
|
||||
)
|
||||
return { value: change.value, rollback: restore }
|
||||
}
|
||||
|
||||
@@ -553,24 +553,33 @@ async function runSeed(seed: number): Promise<string[]> {
|
||||
const ptyId = ids.pty(worktreeId)
|
||||
const ticks = Math.floor(random() * 4)
|
||||
log.push(`${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId} stale-save+${ticks}`)
|
||||
// `terminal.split` from a client or the CLI: main mints the pane while the window's
|
||||
// in-flight save lacks it.
|
||||
// `terminal.split` from a client or the CLI: main writes the pane, then binds it, while
|
||||
// the window's in-flight save lacks it.
|
||||
writer = 'host split'
|
||||
const [bound] = await Promise.all([
|
||||
store.persistPtyBinding(() => {
|
||||
writer = 'host split'
|
||||
return {
|
||||
worktreeId,
|
||||
store
|
||||
.admitTerminalPane({
|
||||
type: 'splitPane',
|
||||
workspace: worktreeId,
|
||||
tabId: target.tabId,
|
||||
leafId,
|
||||
ptyId,
|
||||
hostAdmittedMembership: true,
|
||||
expectedSourceBinding: {
|
||||
leafId: target.leafId,
|
||||
direction: 'vertical',
|
||||
newLeafId: leafId
|
||||
})
|
||||
.then(() =>
|
||||
store.persistPtyBinding({
|
||||
worktreeId,
|
||||
tabId: target.tabId,
|
||||
leafId: target.leafId,
|
||||
ptyId: target.pane.ptyId!
|
||||
}
|
||||
}
|
||||
}),
|
||||
leafId,
|
||||
ptyId,
|
||||
mayCreate: false,
|
||||
expectedSourceBinding: {
|
||||
tabId: target.tabId,
|
||||
leafId: target.leafId,
|
||||
ptyId: target.pane.ptyId!
|
||||
}
|
||||
})
|
||||
),
|
||||
staleSave(window.snapshot(), ticks)
|
||||
])
|
||||
expect(bound).toBe(true)
|
||||
|
||||
@@ -24,7 +24,7 @@ export function buildHeadlessTerminalSplitLayout(
|
||||
direction: 'horizontal' | 'vertical'
|
||||
}
|
||||
): TerminalLayoutSnapshot {
|
||||
// Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit.
|
||||
// Why: the stored tree may already hold the new leaf; it is placed again beside its source.
|
||||
const currentRoot = existing?.root ? removeLayoutLeaf(existing.root, args.leafId) : null
|
||||
const existingRoot: TerminalPaneLayoutNode = currentRoot ?? {
|
||||
type: 'leaf',
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
buildHeadlessMobileSessionTabGroups,
|
||||
cloneTerminalLayoutSnapshot
|
||||
} from './mobile-session-layout-projection'
|
||||
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
|
||||
|
||||
export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh {
|
||||
protected applyMobileSessionTabNavigation(
|
||||
@@ -171,38 +170,6 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
// Why: a headless split only updated the LIVE session snapshot, never the
|
||||
// persisted workspace session layout. So a later snapshot rebuild (e.g. on the
|
||||
// next terminal create) re-derived from the stale single-leaf persisted layout
|
||||
// and collapsed the split. Persist the new split leaf into the workspace
|
||||
// session's terminalLayoutsByTabId so the split survives rebuilds.
|
||||
protected persistHeadlessTerminalSplit(args: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
splitFromLeafId: string
|
||||
direction: 'horizontal' | 'vertical'
|
||||
}): boolean {
|
||||
const session = this.getWorkspaceSessionForWorktree(args.worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return false
|
||||
}
|
||||
const existing = session.terminalLayoutsByTabId?.[args.tabId]
|
||||
const nextLayout = buildHeadlessTerminalSplitLayout(
|
||||
existing ? cloneTerminalLayoutSnapshot(existing) : undefined,
|
||||
args
|
||||
)
|
||||
this.setWorkspaceSessionForWorktree(args.worktreeId, {
|
||||
...session,
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[args.tabId]: nextLayout
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
protected persistHeadlessTerminalActiveLeaf(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
|
||||
@@ -259,29 +259,6 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu
|
||||
}
|
||||
}
|
||||
|
||||
protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index]))
|
||||
const tabs = session.tabsByWorktree[worktreeId] ?? []
|
||||
const reordered = [...tabs]
|
||||
.sort((a, b) => {
|
||||
const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt
|
||||
})
|
||||
.map((tab, index) => ({ ...tab, sortOrder: index }))
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, {
|
||||
...session,
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[worktreeId]: reordered
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void {
|
||||
if (this.mobileSessionTabListeners.size === 0) {
|
||||
return
|
||||
|
||||
@@ -111,6 +111,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
|
||||
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
|
||||
...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}),
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
...(opts.identity
|
||||
? {
|
||||
@@ -181,10 +182,6 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
|
||||
}
|
||||
const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}`
|
||||
const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID()
|
||||
if (opts.viewMode) {
|
||||
// Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default.
|
||||
this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode })
|
||||
}
|
||||
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const existingSurface =
|
||||
existing?.tabs.find(
|
||||
|
||||
@@ -29,7 +29,8 @@ export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view-
|
||||
export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors'
|
||||
export type { RuntimePtyController } from './runtime-pty-controller-contract'
|
||||
export { getRuntimeDesktopSurface } from './runtime-desktop-surface'
|
||||
export { runtimeNewTabPlacement } from './runtime-terminal-spawn-placement'
|
||||
export { newTabIn } from './runtime-terminal-spawn-placement'
|
||||
export { spawnInAdmittedPane } from './runtime-pane-admission'
|
||||
export type { IpcMainEvent } from 'electron'
|
||||
|
||||
// Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse
|
||||
|
||||
@@ -123,7 +123,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
try {
|
||||
const { launchAgent } = launchOpts
|
||||
launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent })
|
||||
result = await this.ptyController.spawn({
|
||||
result = await dependencies.spawnInAdmittedPane(this.store, this.ptyController, {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
@@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
? { onPtySpawnCommitted: reportPtySpawnCommitted }
|
||||
: {}),
|
||||
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
|
||||
placement: dependencies.runtimeNewTabPlacement(),
|
||||
...dependencies.newTabIn(launchOpts),
|
||||
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
|
||||
...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}),
|
||||
...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS
|
||||
|
||||
@@ -69,10 +69,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
tabGroups: nextGroups,
|
||||
tabs: nextTabs
|
||||
}
|
||||
this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder)
|
||||
if (nextGroups.length > 1 && snapshot.tabGroupLayout) {
|
||||
this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout)
|
||||
}
|
||||
this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout)
|
||||
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
return { moved: true }
|
||||
@@ -155,18 +152,50 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
return { moved: true }
|
||||
}
|
||||
|
||||
// Persist the headless tab-GROUP layout so snapshot rebuilds keep the split.
|
||||
// Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows
|
||||
// follow the groups. Deleted by the switch (PR 6), when moves are layout commands.
|
||||
protected persistHeadlessTabGroups(
|
||||
worktreeId: string,
|
||||
groups: readonly RuntimeMobileSessionTabGroup[],
|
||||
layout: TabGroupLayoutNode
|
||||
layout: TabGroupLayoutNode | undefined
|
||||
): void {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const order = groups.flatMap((group) => group.tabOrder)
|
||||
const groupIdByTabId = new Map(
|
||||
groups.flatMap((group) => group.tabOrder.map((tabId) => [tabId, group.id] as const))
|
||||
)
|
||||
const position = (tabId: string): number => {
|
||||
const index = order.indexOf(tabId)
|
||||
return index === -1 ? order.length : index
|
||||
}
|
||||
const entries = session.unifiedTabs?.[worktreeId]
|
||||
const rows = session.tabsByWorktree[worktreeId]
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, {
|
||||
...session,
|
||||
...(rows
|
||||
? {
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[worktreeId]: [...rows]
|
||||
.sort((a, b) => position(a.id) - position(b.id) || a.sortOrder - b.sortOrder)
|
||||
.map((row, sortOrder) => ({ ...row, sortOrder }))
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(entries
|
||||
? {
|
||||
unifiedTabs: {
|
||||
...session.unifiedTabs,
|
||||
[worktreeId]: entries.map((entry) => ({
|
||||
...entry,
|
||||
groupId: groupIdByTabId.get(entry.id) ?? entry.groupId
|
||||
}))
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
tabGroups: {
|
||||
...session.tabGroups,
|
||||
[worktreeId]: groups.map((group) => ({
|
||||
@@ -177,10 +206,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
|
||||
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
|
||||
}))
|
||||
},
|
||||
tabGroupLayouts: {
|
||||
...session.tabGroupLayouts,
|
||||
[worktreeId]: layout
|
||||
}
|
||||
...(layout ? { tabGroupLayouts: { ...session.tabGroupLayouts, [worktreeId]: layout } } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { runtimeSplitPlacement } from './runtime-terminal-spawn-placement'
|
||||
import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { spawnInAdmittedPane, withdrawRuntimePane } from './runtime-pane-admission'
|
||||
|
||||
export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal {
|
||||
protected async splitPtyBackedTerminal(
|
||||
@@ -56,7 +57,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
|
||||
const leafId = randomUUID()
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const paneKey = makePaneKey(parentTabId, leafId)
|
||||
const result = await this.ptyController.spawn({
|
||||
const splitArgs = {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: workspace.path,
|
||||
@@ -89,7 +90,8 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
const result = await spawnInAdmittedPane(this.store, this.ptyController, splitArgs)
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
@@ -150,17 +152,6 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
|
||||
revalidateSourceAuthority()
|
||||
}
|
||||
if (createdPty) {
|
||||
const persisted = this.persistHeadlessTerminalSplit({
|
||||
worktreeId: workspace.id,
|
||||
tabId: parentTabId,
|
||||
leafId,
|
||||
ptyId: createdPty.ptyId,
|
||||
splitFromLeafId: parsedPaneKey.leafId,
|
||||
direction
|
||||
})
|
||||
if (sourceAuthority.persisted && !persisted) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
|
||||
tabId: parentTabId,
|
||||
leafId,
|
||||
@@ -192,6 +183,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
|
||||
} catch {
|
||||
// Best-effort cleanup; preserve the original split authority error.
|
||||
}
|
||||
await withdrawRuntimePane(this.store, splitArgs)
|
||||
throw error
|
||||
}
|
||||
const committedSourceAuthority = sourceAuthority.persisted
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withRuntimePaneAdmission } from './runtime-durable-store-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
@@ -96,7 +97,7 @@ function createHarness(
|
||||
addedAt: 1,
|
||||
...(connectionId ? { connectionId } : {})
|
||||
}
|
||||
const store = {
|
||||
const store = withRuntimePaneAdmission({
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
|
||||
getWorkspaceSession: (hostId?: string) => {
|
||||
@@ -107,7 +108,7 @@ function createHarness(
|
||||
session = next
|
||||
},
|
||||
persistPtyBinding: () => true
|
||||
}
|
||||
})
|
||||
let resolveSpawn: ((result: { id: string }) => void) | undefined
|
||||
const spawn = options.deferSpawn
|
||||
? vi.fn(
|
||||
@@ -319,7 +320,6 @@ describe('remote runtime terminal split authority', () => {
|
||||
[SOURCE_LEAF_ID]: SOURCE_PTY_ID
|
||||
}
|
||||
})
|
||||
expect(Object.values(persistedLayout!.ptyIdsByLeafId!)).toContain(SPLIT_PTY_ID)
|
||||
const siblingSurfaces = harness
|
||||
.getSnapshot()!
|
||||
.tabs.filter(
|
||||
@@ -432,37 +432,20 @@ describe('remote runtime terminal split authority', () => {
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true)
|
||||
})
|
||||
|
||||
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
|
||||
const harness = createHarness(false, {
|
||||
deferReveal: true,
|
||||
includePairedSnapshot: true,
|
||||
sourceIncarnationId: 'projected-before',
|
||||
stopAndWaitResult: false
|
||||
})
|
||||
it('refuses a split whose source is only projected, before spawning', async () => {
|
||||
const harness = createHarness(false, { includePairedSnapshot: true })
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
|
||||
)
|
||||
|
||||
harness.replaceSourceIncarnation('projected-after')
|
||||
harness.resolveReveal()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(
|
||||
SPLIT_PTY_ID,
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
|
||||
await expect(
|
||||
harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.spawn).not.toHaveBeenCalled()
|
||||
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the split error when kill and retirement throw', async () => {
|
||||
const harness = createHarness(false, {
|
||||
deferReveal: true,
|
||||
includePairedSnapshot: true,
|
||||
sourceIncarnationId: 'projected-before',
|
||||
it('preserves the split error and takes back the pane when kill and retirement throw', async () => {
|
||||
const harness = createHarness(true, {
|
||||
deferSpawn: true,
|
||||
includePairedSnapshot: false,
|
||||
stopAndWaitResult: false
|
||||
})
|
||||
harness.kill.mockImplementation(() => {
|
||||
@@ -471,14 +454,19 @@ describe('remote runtime terminal split authority', () => {
|
||||
harness.retireRejectedPty.mockImplementation(() => {
|
||||
throw new Error('retire failed')
|
||||
})
|
||||
harness.replacePersistedSourceIncarnation('persisted-before')
|
||||
|
||||
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
|
||||
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
|
||||
harness.replaceSourceIncarnation('projected-after')
|
||||
harness.resolveReveal()
|
||||
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
|
||||
harness.replacePersistedSourceIncarnation('persisted-after')
|
||||
harness.resolveSpawn()
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
|
||||
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId: SOURCE_LEAF_ID
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { withDurableRuntimeStore } from './runtime-durable-store-fixture'
|
||||
import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture'
|
||||
import { expect, vi } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
@@ -580,46 +580,48 @@ function makeRuntimeStoreWithWorkspaceSession(
|
||||
const setSession = (next: WorkspaceSessionState): void => {
|
||||
session = next
|
||||
}
|
||||
const runtimeStore = withDurableRuntimeStore({
|
||||
...store,
|
||||
getWorkspaceSession: (hostId?: string) =>
|
||||
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
|
||||
setWorkspaceSession: vi.fn(setSession),
|
||||
// Headless close is a durable transaction; keep the in-memory fixture's
|
||||
// persistence contract equivalent to the production store.
|
||||
flushOrThrow: vi.fn(),
|
||||
persistPtyBinding: vi.fn<Store['persistPtyBinding']>(async (input) => {
|
||||
const args = typeof input === 'function' ? input() : input
|
||||
if (!args) {
|
||||
return false
|
||||
}
|
||||
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
|
||||
session = {
|
||||
...session,
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[args.worktreeId]: tabs.map((tab) =>
|
||||
tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab
|
||||
)
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[args.tabId]: {
|
||||
...(session.terminalLayoutsByTabId[args.tabId] ?? {
|
||||
root: { type: 'leaf', leafId: args.leafId },
|
||||
activeLeafId: args.leafId,
|
||||
expandedLeafId: null
|
||||
}),
|
||||
ptyIdsByLeafId: {
|
||||
...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId,
|
||||
[args.leafId]: args.ptyId
|
||||
const runtimeStore = withRuntimePaneAdmission(
|
||||
withDurableRuntimeStore({
|
||||
...store,
|
||||
getWorkspaceSession: (hostId?: string) =>
|
||||
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
|
||||
setWorkspaceSession: vi.fn(setSession),
|
||||
// Headless close is a durable transaction; keep the in-memory fixture's
|
||||
// persistence contract equivalent to the production store.
|
||||
flushOrThrow: vi.fn(),
|
||||
persistPtyBinding: vi.fn<Store['persistPtyBinding']>(async (input) => {
|
||||
const args = typeof input === 'function' ? input() : input
|
||||
if (!args) {
|
||||
return false
|
||||
}
|
||||
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
|
||||
session = {
|
||||
...session,
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[args.worktreeId]: tabs.map((tab) =>
|
||||
tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab
|
||||
)
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[args.tabId]: {
|
||||
...(session.terminalLayoutsByTabId[args.tabId] ?? {
|
||||
root: { type: 'leaf', leafId: args.leafId },
|
||||
activeLeafId: args.leafId,
|
||||
expandedLeafId: null
|
||||
}),
|
||||
ptyIdsByLeafId: {
|
||||
...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId,
|
||||
[args.leafId]: args.ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return true
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
return { runtimeStore, getSession: () => session, setSession }
|
||||
}
|
||||
|
||||
|
||||
@@ -176,4 +176,55 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(listed.tabGroups).toHaveLength(1)
|
||||
expect(storedActiveGroupId(runtime)).toBe(listed.tabGroups?.[0]?.id)
|
||||
})
|
||||
|
||||
it('keeps phone and CLI creates in the group they joined across a cold restart', async () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeTabId: 'serve-tab',
|
||||
activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' },
|
||||
activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-left' },
|
||||
tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS },
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
makeTerminalTab('serve-tab', SERVE_PTY_ID, 0),
|
||||
makeTerminalTab('daemon-tab', DAEMON_PTY_ID, 1)
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }),
|
||||
'daemon-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: DAEMON_PTY_ID })
|
||||
}
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
let ptys = 0
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: `created-pty-${++ptys}` })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
// The phone targets the right group; an untargeted CLI create joins the first, as on main.
|
||||
const phone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
targetGroupId: 'group-right'
|
||||
})
|
||||
const cli = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`)
|
||||
const tabOrderOf = (session: WorkspaceSessionState, groupId: string) =>
|
||||
session.tabGroups?.[TEST_WORKTREE_ID]?.find((group) => group.id === groupId)?.tabOrder
|
||||
expect(tabOrderOf(getSession(), 'group-right')).toContain(phone.tab.parentTabId)
|
||||
expect(tabOrderOf(getSession(), 'group-left')).toContain(cli.tabId)
|
||||
|
||||
const restarted = new OrcaRuntimeService(
|
||||
makeRuntimeStoreWithWorkspaceSession(getSession()).runtimeStore
|
||||
)
|
||||
const listed = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(listed.tabGroups?.map((group) => group.tabOrder)).toEqual([
|
||||
['serve-tab', cli.tabId],
|
||||
['daemon-tab', phone.tab.parentTabId]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
TEST_WORKTREE_PATH,
|
||||
UUID_RE,
|
||||
deferred,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
store
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('keeps client selection when a renderer session-tab close cannot commit', async () => {
|
||||
@@ -249,17 +251,10 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
it('creates mobile session terminals in a headless runtime server', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const persistViewMode = vi.spyOn(
|
||||
runtime as unknown as {
|
||||
persistHeadlessSessionTabProps: (
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
props: { viewMode: 'terminal' | 'chat' }
|
||||
) => void
|
||||
},
|
||||
'persistHeadlessSessionTabProps'
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
getDefaultWorkspaceSession()
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
@@ -289,9 +284,12 @@ describe('OrcaRuntimeService', () => {
|
||||
viewMode: 'chat',
|
||||
isActive: true
|
||||
})
|
||||
expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, {
|
||||
viewMode: 'chat'
|
||||
})
|
||||
// Written with the pane before spawn, so a serve restart keeps the initial mode.
|
||||
expect(
|
||||
getSession().tabsByWorktree[TEST_WORKTREE_ID]?.find(
|
||||
(tab) => tab.id === result.tab.parentTabId
|
||||
)?.viewMode
|
||||
).toBe('chat')
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(listed.tabs).toEqual([
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
@@ -10,7 +11,11 @@ import {
|
||||
runHook,
|
||||
shouldRunSetupForCreate
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import { expectStablePaneKeyEnv, store } from '../orca-runtime-test-fixtures.spec'
|
||||
import {
|
||||
expectStablePaneKeyEnv,
|
||||
store,
|
||||
makeRuntimeStoreWithWorkspaceSession
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('sequences setup before startup for opted-in local headless worktree creates', async () => {
|
||||
@@ -517,7 +522,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
it('honors split setup placement for CLI-created worktrees without startup agents', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
setupScriptLaunchMode: 'split-vertical' as const
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
@@ -14,12 +15,17 @@ import {
|
||||
shouldRunSetupForCreate
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec'
|
||||
import {
|
||||
TEST_REPO_ID,
|
||||
makeWorktreeMeta,
|
||||
store,
|
||||
makeRuntimeStoreWithWorkspaceSession
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('does not surface the new workspace when a background create splits its setup pane', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
setupScriptLaunchMode: 'split-horizontal' as const
|
||||
@@ -102,7 +108,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
it('still surfaces the new workspace when an activating create splits its setup pane', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
setupScriptLaunchMode: 'split-horizontal' as const
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
OrcaRuntimeService,
|
||||
@@ -14,7 +15,12 @@ import {
|
||||
shouldRunSetupForCreate
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec'
|
||||
import {
|
||||
TEST_REPO_ID,
|
||||
makeWorktreeMeta,
|
||||
store,
|
||||
makeRuntimeStoreWithWorkspaceSession
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('sends follow-up prompts for CLI-created stdin-after-start startup agents', async () => {
|
||||
@@ -233,7 +239,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('honors split setup placement for opted-in local startup-draft worktrees', async () => {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
defaultTuiAgent: 'codex' as const,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state'
|
||||
import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors'
|
||||
import { resolveHostId } from '../persistence/loading-store/session-host-partitions'
|
||||
import {
|
||||
admitPaneToSession,
|
||||
withdrawPaneFromSession,
|
||||
type PaneLayoutChange,
|
||||
type TerminalPaneAdmission
|
||||
} from '../persistence/terminal-topology/terminal-pane-admission'
|
||||
import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
|
||||
/** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */
|
||||
export function withDurableRuntimeStore<
|
||||
@@ -38,3 +47,29 @@ export function withDurableRuntimeStore<
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Gives a runtime fake the Store's pane admission, over the fake's own session accessors. */
|
||||
export function withRuntimePaneAdmission<
|
||||
T extends {
|
||||
getWorkspaceSession?: (hostId?: string) => WorkspaceSessionState
|
||||
setWorkspaceSession?: (session: WorkspaceSessionState, hostId?: string) => void
|
||||
}
|
||||
>(store: T) {
|
||||
const commit = <V>(
|
||||
hostId: string | undefined,
|
||||
change: (session: WorkspaceSessionState) => PaneLayoutChange<V>
|
||||
): V => {
|
||||
const resolved = resolveHostId(hostId)
|
||||
const { value, session } = change(store.getWorkspaceSession!(resolved))
|
||||
if (session) {
|
||||
store.setWorkspaceSession!(session, resolved)
|
||||
}
|
||||
return value
|
||||
}
|
||||
return Object.assign(store, {
|
||||
admitTerminalPane: async (admission: TerminalPaneAdmission, hostId?: string) =>
|
||||
commit(hostId, (session) => admitPaneToSession(resolveHostId(hostId), session, admission)),
|
||||
withdrawTerminalPane: async (pane: CommandOf<'closePane'>, hostId?: string) =>
|
||||
commit(hostId, (session) => withdrawPaneFromSession(resolveHostId(hostId), session, pane))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import type { TerminalPaneAdmission } from '../persistence/terminal-topology/terminal-pane-admission'
|
||||
import type { RuntimePtyController } from './runtime-pty-controller-contract'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
type Spawn = NonNullable<RuntimePtyController['spawn']>
|
||||
type AdmittedSpawnArgs = Parameters<Spawn>[0] & {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
/** The group a new tab joins; a split stays in its parent's. */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
/** The pane its placement names: a new tab, or a split beside its parent pane. */
|
||||
function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission {
|
||||
const { worktreeId: workspace, tabId, leafId, placement } = args
|
||||
if (placement?.kind === 'split') {
|
||||
const { parentLeafId, direction } = placement
|
||||
return {
|
||||
type: 'splitPane',
|
||||
workspace,
|
||||
tabId,
|
||||
leafId: parentLeafId,
|
||||
direction,
|
||||
newLeafId: leafId
|
||||
}
|
||||
}
|
||||
const viewMode = placement?.kind === 'new-tab' ? placement.row?.viewMode : undefined
|
||||
return {
|
||||
type: 'createTerminalTab',
|
||||
workspace,
|
||||
tabId,
|
||||
leafId,
|
||||
...(args.groupId ? { groupId: args.groupId } : {}),
|
||||
...(viewMode ? { viewMode } : {}),
|
||||
...(args.cwd ? { creation: { startupCwd: args.cwd } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function hostIdOf(args: AdmittedSpawnArgs): string | undefined {
|
||||
return args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
|
||||
}
|
||||
|
||||
/** Takes back a pane the runtime wrote for a start that then failed, so it leaves no tab. */
|
||||
export async function withdrawRuntimePane(
|
||||
store: RuntimeStore | null | undefined,
|
||||
args: AdmittedSpawnArgs
|
||||
): Promise<void> {
|
||||
await store?.withdrawTerminalPane?.(
|
||||
{ type: 'closePane', workspace: args.worktreeId, tabId: args.tabId, leafId: args.leafId },
|
||||
hostIdOf(args)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function spawnInAdmittedPane(
|
||||
store: RuntimeStore | null | undefined,
|
||||
controller: Required<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 {
|
||||
result = await controller.spawn(spawnArgs)
|
||||
} catch (error) {
|
||||
if (outcome === 'admitted') {
|
||||
await withdrawRuntimePane(store, args)
|
||||
}
|
||||
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) {
|
||||
await withdrawRuntimePane(store, args)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export type RuntimeStore = {
|
||||
abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog']
|
||||
getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState']
|
||||
persistPtyBinding?: Store['persistPtyBinding']
|
||||
admitTerminalPane?: Store['admitTerminalPane']
|
||||
withdrawTerminalPane?: Store['withdrawTerminalPane']
|
||||
getSshRemotePtyLeases?: Store['getSshRemotePtyLeases']
|
||||
getUI?: Store['getUI']
|
||||
updateUI?: Store['updateUI']
|
||||
|
||||
@@ -67,6 +67,8 @@ export type TerminalCreateOptions = {
|
||||
terminalKittyKeyboardProtocol?: boolean
|
||||
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
/** The tab group a background create joins; defaults to the first group. */
|
||||
targetGroupId?: string
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
telemetry?: WorktreeStartupLaunch['telemetry']
|
||||
/** The surface that asked for this `startupAgent` launch; the runtime attributes every one it
|
||||
|
||||
@@ -47,8 +47,7 @@ export function buildRuntimeTerminalOrphanSession(args: {
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: tabsById.size,
|
||||
createdAt: Date.now(),
|
||||
pendingActivationSpawn: true
|
||||
createdAt: Date.now()
|
||||
}
|
||||
tabsById.set(claim.tabId, tab)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,20 @@ import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement
|
||||
import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types'
|
||||
|
||||
/** A main-side create always opens its own tab. */
|
||||
export function runtimeNewTabPlacement(): TerminalPanePlacement {
|
||||
return { kind: 'new-tab' }
|
||||
export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): TerminalPanePlacement {
|
||||
return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' }
|
||||
}
|
||||
|
||||
/** A runtime-started tab opens in the group asked for, else the first group (as the live view). */
|
||||
export function newTabIn(opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }): {
|
||||
placement: TerminalPanePlacement
|
||||
groupId?: string
|
||||
} {
|
||||
const { targetGroupId } = opts
|
||||
return {
|
||||
placement: runtimeNewTabPlacement(opts.viewMode),
|
||||
...(targetGroupId ? { groupId: targetGroupId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeSplitPlacement(
|
||||
|
||||
@@ -17,6 +17,9 @@ type On = { workspace: string }
|
||||
export type LayoutCommand =
|
||||
| (On & {
|
||||
type: 'createTerminalTab'
|
||||
/** Ids the runtime minted before applying, e.g. a retried create's. */
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
groupId?: string
|
||||
afterTabId?: string
|
||||
index?: number
|
||||
@@ -31,6 +34,8 @@ export type LayoutCommand =
|
||||
leafId: string
|
||||
direction: TerminalPaneSplitDirection
|
||||
ratio?: number
|
||||
/** The new pane's id, when the runtime minted it before applying. */
|
||||
newLeafId?: string
|
||||
})
|
||||
| (On & { type: 'closePane'; tabId: string; leafId: string })
|
||||
| (On & {
|
||||
|
||||
@@ -123,8 +123,8 @@ const ROW = {
|
||||
startupCwd: own('tab.terminal', 'startupCwd'),
|
||||
launchAgent: own('tab.terminal', 'launchAgent'),
|
||||
agentLaunchPane: own('tab.terminal', 'agentLaunchPane'),
|
||||
// Transient handoffs, never restored; the window strips them, but main's minimal row mint
|
||||
// stores `pendingActivationSpawn`, so the Loader drops it.
|
||||
// Transient handoffs, never restored; the window strips them, but older builds' minimal row mint
|
||||
// stored `pendingActivationSpawn`, so the Loader drops it.
|
||||
pendingActivationSpawn: 'unwritten',
|
||||
recovery: 'unwritten',
|
||||
restoredFromSession: 'unwritten'
|
||||
|
||||
@@ -46,7 +46,7 @@ export function splitPane(
|
||||
if (!layoutContainsLeafId(tab.panes.root, command.leafId)) {
|
||||
return refuse('pane_not_found')
|
||||
}
|
||||
const leafId = context.mintLeafId()
|
||||
const leafId = command.newLeafId ?? context.mintLeafId()
|
||||
const side = command.direction === 'vertical' ? 'right' : 'bottom'
|
||||
const root = insertLeafBeside(tab.panes.root!, command.leafId, leafId, side, command.ratio)
|
||||
const next = updateTab(model, command.workspace, { ...tab, panes: { ...tab.panes, root } })
|
||||
|
||||
@@ -42,7 +42,7 @@ export const KNOWN_LOAD_CHANGES: ReadonlySet<string> = new Set([
|
||||
'group.recentTabIds',
|
||||
'row.title',
|
||||
'row.ptyId',
|
||||
// Transient handoffs main's minimal row mint stores; never restored.
|
||||
// Transient handoffs older builds' minimal row mint stored; never restored.
|
||||
'row.pendingActivationSpawn',
|
||||
'row.recovery',
|
||||
'row.customTitle',
|
||||
|
||||
@@ -40,8 +40,8 @@ export function createTerminalTab(
|
||||
const ordinal = getNextTerminalOrdinal(
|
||||
terminals.map((tab) => ({ defaultTitle: tab.terminal.defaultTitle, title: '' }))
|
||||
)
|
||||
const id = context.mintId()
|
||||
const leafId = context.mintLeafId()
|
||||
const id = command.tabId ?? context.mintId()
|
||||
const leafId = command.leafId ?? context.mintLeafId()
|
||||
const tab: LayoutTerminalTab = {
|
||||
id,
|
||||
entityId: id,
|
||||
|
||||
@@ -97,10 +97,10 @@ async function handleForTab(run: Run, tabId: string): Promise<string> {
|
||||
return handle!
|
||||
}
|
||||
|
||||
async function createTab(run: Run, client: RuntimeClient): Promise<string> {
|
||||
async function createTab(run: Run, client: RuntimeClient, targetGroupId?: string): Promise<string> {
|
||||
const created = await client.call<{ tab: { parentTabId: string } }>(
|
||||
'session.tabs.createTerminal',
|
||||
{ worktree: run.worktree }
|
||||
{ worktree: run.worktree, ...(targetGroupId ? { targetGroupId } : {}) }
|
||||
)
|
||||
return created.result.tab.parentTabId
|
||||
}
|
||||
@@ -304,6 +304,42 @@ const SCENARIOS: Scenario[] = [
|
||||
await run.restart('warm restart', [1, 1])
|
||||
}
|
||||
},
|
||||
{
|
||||
// #27083 review: a phone create joins the group it targets, and an untargeted CLI create the
|
||||
// first group, across a cold restart.
|
||||
id: 'create-in-second-group-cold-restart',
|
||||
journey: async (run) => {
|
||||
const first = await createTab(run, run.a)
|
||||
const second = await createTab(run, run.a)
|
||||
await run.check('two tabs', [1, 1])
|
||||
const [source] = await groupsOf(run)
|
||||
const [, target] = await moveTab(
|
||||
run,
|
||||
'split second tab right',
|
||||
{ kind: 'split', tabId: second, targetGroupId: source!.id, splitDirection: 'right' },
|
||||
[[first], [second]]
|
||||
)
|
||||
const phone = await createTab(run, run.a, target!.id)
|
||||
await run.check('phone create in second group', [1, 1, 1])
|
||||
const membership = async () =>
|
||||
JSON.stringify((await groupsOf(run)).map((group) => group.tabOrder.toSorted()))
|
||||
const joined = await membership()
|
||||
const phoneExpected = JSON.stringify([[first], [second, phone].toSorted()])
|
||||
run.expectNone(
|
||||
'phone create joins the second group',
|
||||
joined === phoneExpected ? [] : [`groups ${joined}, expected ${phoneExpected}`]
|
||||
)
|
||||
const cli = (await createTerminal(run)).tabId!
|
||||
await run.check('CLI create in first group', [1, 1, 1, 1])
|
||||
await run.restart('cold restart', [1, 1, 1, 1], true)
|
||||
const after = await membership()
|
||||
const expected = JSON.stringify([[first, cli].toSorted(), [second, phone].toSorted()])
|
||||
run.expectNone(
|
||||
'created tabs stay in their groups after a cold restart',
|
||||
after === expected ? [] : [`groups ${after}, expected ${expected}`]
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
// Moves between groups and a reorder; a group emptied by a move closes.
|
||||
id: 'move-tab-between-groups',
|
||||
|
||||
@@ -84,20 +84,6 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
|
||||
step: 'relaunch',
|
||||
cause: 'tab bar not saved after wake'
|
||||
},
|
||||
// Headless runtimes save tab rows and no tab bar; a close then saves a tab bar that lacks the
|
||||
// tabs still open.
|
||||
{
|
||||
scenario: 'headless-*',
|
||||
check: 'rules',
|
||||
rule: 'tab_bar_missing',
|
||||
cause: 'headless saves no tab bar'
|
||||
},
|
||||
{
|
||||
scenario: 'headless-*',
|
||||
check: 'rules',
|
||||
rule: 'tab_lists_disagree',
|
||||
cause: 'headless close saves a partial tab bar'
|
||||
},
|
||||
// After a relaunch main holds the tab rows but no tab bar for the worktree until the window
|
||||
// saves again; slow enough on Linux CI to outlast the settle wait.
|
||||
...(['tab_lists_disagree', 'tab_bar_missing'] as const).flatMap((rule) =>
|
||||
@@ -132,16 +118,15 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
|
||||
step: 'rename after restart',
|
||||
cause: 'orcad title after restart'
|
||||
},
|
||||
// Headless group moves save the groups but still no tab bar, so the groups list tabs the tab bar
|
||||
// lacks.
|
||||
...(['orcad', 'electron'] as const).flatMap((kind) =>
|
||||
['split-tab-into-new-group', 'move-tab-between-groups'].map((id) => ({
|
||||
scenario: `headless-${kind}-${id}`,
|
||||
check: 'rules' as const,
|
||||
rule: 'group_lists_missing_tab',
|
||||
cause: 'headless saves groups but no tab bar'
|
||||
}))
|
||||
),
|
||||
// A headless reorder saves rows through the session merge door, which keeps the stored row order,
|
||||
// so rows and the group disagree; hidden until creates saved a tab bar. Fixed in PR 6 with
|
||||
// client-reorder-tabs.
|
||||
...(['orcad', 'electron'] as const).map((kind) => ({
|
||||
scenario: `headless-${kind}-move-tab-between-groups`,
|
||||
check: 'rules' as const,
|
||||
rule: 'tab_order_disagrees',
|
||||
cause: 'one tab order'
|
||||
})),
|
||||
// After a headless move into an existing group, paired clients still see that group's previous
|
||||
// tab selected, not the moved one. A split into a new group selects the moved tab.
|
||||
...(['orcad', 'electron'] as const).map((kind) => ({
|
||||
|
||||
Reference in New Issue
Block a user