From 8675000479f84c9a0fb8e331430f9411d22e4870 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 21:29:44 -0400 Subject: [PATCH 1/4] Write runtime-started panes with their tab bar entry and group before they spawn Co-Authored-By: Claude --- .../ipc/pty-pane-claim-arbitration.test.ts | 4 +- .../pty-pane-reservation-settlement.test.ts | 2 +- ...ty-runtime-ssh-binding-persistence.test.ts | 2 +- .../ipc/pty-spawn-placement-threading.test.ts | 2 +- src/main/ipc/pty/runtime/spawn-commit.ts | 3 +- ...time-admitted-terminal-membership.test.ts} | 131 +++++++++++++++--- ...profile-state-delayed-authority-fixture.ts | 18 +++ .../loading-store/pty-binding-persistence.ts | 58 ++++++-- .../pty-binding-placement-inertness.test.ts | 6 +- .../pty-binding-session-update.ts | 10 +- .../pty-reattach-failure-routing.test.ts | 3 +- .../pty-spawn-exit-durability.test.ts | 3 +- .../pty-spawn-handle-publication.test.ts | 3 +- .../pty-spawn-replacement-durability.test.ts | 4 +- .../pty-spawn-restore-durability.test.ts | 3 +- .../session-owner-fields.ts | 3 +- .../terminal-topology/terminal-leaf-move.ts | 4 +- .../terminal-pane-admission.ts | 66 +++++++++ .../terminal-stale-pane-tree-client.test.ts | 14 +- .../terminal-topology-commit.ts | 75 +++++++++- .../terminal-topology-model.test.ts | 39 ++++-- .../runtime/headless-terminal-split-layout.ts | 2 +- ...ime-apply-mobile-session-tab-navigation.ts | 33 ----- ...ld-headless-mobile-session-browser-tabs.ts | 23 --- ...e-runtime-owned-mobile-session-terminal.ts | 4 - ...ca-runtime-create-terminal-dependencies.ts | 1 + .../runtime/orca-runtime-create-terminal.ts | 4 +- ...untime-move-headless-mobile-session-tab.ts | 46 ++++-- .../orca-runtime-split-pty-backed-terminal.ts | 18 +-- ...a-runtime-terminal-split-authority.test.ts | 56 +++----- .../orca-runtime-test-fixtures.spec.ts | 76 +++++----- .../mobile-session-tabs-part-03.spec.ts | 24 ++-- ...worktree-setup-and-startup-part-02.spec.ts | 9 +- ...worktree-setup-and-startup-part-03.spec.ts | 12 +- ...worktree-setup-and-startup-part-04.spec.ts | 10 +- .../runtime/runtime-durable-store-fixture.ts | 35 +++++ src/main/runtime/runtime-pane-admission.ts | 83 +++++++++++ src/main/runtime/runtime-store-contract.ts | 2 + ...untime-terminal-orphan-session-adoption.ts | 3 +- .../runtime-terminal-spawn-placement.ts | 4 +- .../workspace-layout-command-types.ts | 5 + .../workspace-layout-disk-fields.ts | 4 +- .../workspace-layout-pane-commands.ts | 2 +- .../workspace-layout-round-trip-check.ts | 2 +- .../workspace-layout-tab-commands.ts | 4 +- .../workspace-layout-oracle-known-on-main.ts | 24 ---- 46 files changed, 650 insertions(+), 289 deletions(-) rename src/main/{persistence-host-admitted-terminal-membership.test.ts => persistence-runtime-admitted-terminal-membership.test.ts} (55%) create mode 100644 src/main/persistence/terminal-topology/terminal-pane-admission.ts create mode 100644 src/main/runtime/runtime-pane-admission.ts diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 86fe3381f2bf..50516ec3398c 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -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' }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..0cee922e8091 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -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' diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index 6af9334293a1..d273b4566ab8 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -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' diff --git a/src/main/ipc/pty-spawn-placement-threading.test.ts b/src/main/ipc/pty-spawn-placement-threading.test.ts index 76b6dd9ce87b..386e74a8fccd 100644 --- a/src/main/ipc/pty-spawn-placement-threading.test.ts +++ b/src/main/ipc/pty-spawn-placement-threading.test.ts @@ -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' }) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 71a9a42bf518..deb361e2ab23 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -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 } : {}), diff --git a/src/main/persistence-host-admitted-terminal-membership.test.ts b/src/main/persistence-runtime-admitted-terminal-membership.test.ts similarity index 55% rename from src/main/persistence-host-admitted-terminal-membership.test.ts rename to src/main/persistence-runtime-admitted-terminal-membership.test.ts index 83a6898c6671..82d748a70d04 100644 --- a/src/main/persistence-host-admitted-terminal-membership.test.ts +++ b/src/main/persistence-runtime-admitted-terminal-membership.test.ts @@ -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>, + pane: { worktreeId: string; tabId: string; leafId: string; ptyId: string; incarnationId?: string } +): Promise { + 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() + }) }) diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index e51ec92b5ec0..e2078f8b9182 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -11,6 +11,7 @@ import type { ProfileStateDomainReplacement } from './profile-state-authority' import { Store } from './store' +import { toSshExecutionHostId } from '../../../shared/execution-host' export function deferred() { 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 { + await store.admitTerminalPane( + { + type: 'createTerminalTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId + }, + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index f96624d8cb1a..bc27b7f86a33 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -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 { - 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 { + return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( + admitPane(resolveHostId(hostId), admission, topologyCommitContext(this)) ) } + + withdrawTerminalPane( + pane: CommandOf<'closePane'>, + hostId?: string | null + ): Promise { + 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( diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index ce412cf4633d..95007aaa380f 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -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'], diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 3024543505a6..dd7ca9e1275f 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -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 ?? []), diff --git a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts index 7a1d95c91cb9..75eeef8061d2 100644 --- a/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts +++ b/src/main/persistence/loading-store/pty-reattach-failure-routing.test.ts @@ -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) diff --git a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts index ef146779b4bf..b39a5eb01c96 100644 --- a/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-exit-durability.test.ts @@ -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) diff --git a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts index 9fb0d65a2cb8..e8cad7828eec 100644 --- a/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-handle-publication.test.ts @@ -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) } diff --git a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts index 19bb64ff3693..75f154074e27 100644 --- a/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-replacement-durability.test.ts @@ -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) } diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts index 26737df9dd0d..714cf99c1155 100644 --- a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -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) } diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index 1ac9fec8935b..88920f660f31 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -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 } : {}) } } diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 627e4a705e72..5248ca7c0d90 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -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) => diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts new file mode 100644 index 000000000000..4e3ab537f446 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -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 = { value: T; session: WorkspaceSessionState | null } + +export function admitPaneToSession( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + admission: TerminalPaneAdmission +): PaneLayoutChange { + 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 { + return applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane, null) +} + +function applyToSession( + loaded: LoadedWorkspaceLayout, + command: LayoutCommand, + committed: T +): PaneLayoutChange { + 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 } +} diff --git a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts index 8de565db5f57..a8000bd20c6e 100644 --- a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts +++ b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts @@ -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) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index dc1b562a51da..756869d08d79 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -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 { + 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 { + 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( + hostId: ExecutionHostId, + change: PaneLayoutChange, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + 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 } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts index a34808fe8a3f..855f61b2de49 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -553,24 +553,33 @@ async function runSeed(seed: number): Promise { 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) diff --git a/src/main/runtime/headless-terminal-split-layout.ts b/src/main/runtime/headless-terminal-split-layout.ts index 946fd05b6c51..871371ee1999 100644 --- a/src/main/runtime/headless-terminal-split-layout.ts +++ b/src/main/runtime/headless-terminal-split-layout.ts @@ -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', diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 055e64c4622f..a9f46fca1dab 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 9a247177d09b..18cd8fdfcd83 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -256,29 +256,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 diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index 0799fd1b17a3..32533b3f9ecc 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -179,10 +179,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( diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index b01e6f7f9a00..412abf41939c 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -28,6 +28,7 @@ export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-que export type { RuntimePtyController } from './runtime-pty-controller-contract' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' export { runtimeNewTabPlacement } 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 diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 8c4ed9ac7bf3..eace2407b86f 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -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(), + placement: dependencies.runtimeNewTabPlacement(launchOpts.viewMode), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index 8caa6bbd9de4..6ee6bd56ed53 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -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 the + // rows follow the groups, so every saved copy of the tab order agrees. 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 } } : {}) }) } } diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index de9890862abd..c87488dd92d9 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index 8369a219331e..a59074a82dc5 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -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 - }) - - 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() + it('refuses a split whose source is only projected, before spawning', async () => { + const harness = createHarness(false, { includePairedSnapshot: true }) - 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 + }) }) }) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 63a29dbc717f..fe61bcbb7da4 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -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(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(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 } } diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts index 1b28f86bd0de..22a822b47881 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-03.spec.ts @@ -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([ diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts index eca4bd125140..0f121cc56d55 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index f40f25da7e48..0b804598cb4a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index c883db499c59..63ba48a6611a 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -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 = {} const runtimeStore = { - ...store, + ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ ...store.getSettings(), defaultTuiAgent: 'codex' as const, diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts index a5f2dc82793a..cbc7f4e3d1f7 100644 --- a/src/main/runtime/runtime-durable-store-fixture.ts +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -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 = ( + hostId: string | undefined, + change: (session: WorkspaceSessionState) => PaneLayoutChange + ): 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)) + }) +} diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts new file mode 100644 index 000000000000..624625283842 --- /dev/null +++ b/src/main/runtime/runtime-pane-admission.ts @@ -0,0 +1,83 @@ +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 +type AdmittedSpawnArgs = Parameters[0] & { + worktreeId: string + tabId: string + leafId: 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, + ...(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 { + 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>, + args: AdmittedSpawnArgs +): ReturnType { + 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') + } + let result: Awaited> + try { + result = await controller.spawn(args) + } 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 +} diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 9a5f69556a18..2e2387c34105 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -57,6 +57,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'] diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index e2a4cd1df5d0..f95c28a4e122 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -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) } diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index ae7503c712bf..29d51eec5da3 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -2,8 +2,8 @@ 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' } } export function runtimeSplitPlacement( diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index c9899da303b9..83874caaf40a 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -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 & { diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index 30f47612c398..e5e1387c10fd 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -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' } as const satisfies Record diff --git a/src/shared/workspace-layout/workspace-layout-pane-commands.ts b/src/shared/workspace-layout/workspace-layout-pane-commands.ts index 1c57d32752d9..1f7002cca713 100644 --- a/src/shared/workspace-layout/workspace-layout-pane-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-pane-commands.ts @@ -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 } }) diff --git a/src/shared/workspace-layout/workspace-layout-round-trip-check.ts b/src/shared/workspace-layout/workspace-layout-round-trip-check.ts index 2f3882b0856c..b18e595022ea 100644 --- a/src/shared/workspace-layout/workspace-layout-round-trip-check.ts +++ b/src/shared/workspace-layout/workspace-layout-round-trip-check.ts @@ -42,7 +42,7 @@ export const KNOWN_LOAD_CHANGES: ReadonlySet = 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', diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 2d96a35a1d1e..d0493b01cba7 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -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, diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index 212a2399a55c..cff86d2bedee 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -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,6 @@ 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' - })) - ), // 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) => ({ From 9ef8c8392df7b5972fb22dadf6c1f38b8f58ae9a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 9 Oct 2026 21:32:29 -0400 Subject: [PATCH 2/4] List the headless reorder's row-order finding and mark the headless group writer for the switch Co-Authored-By: Claude --- .../orca-runtime-move-headless-mobile-session-tab.ts | 4 ++-- tests/e2e/workspace-layout-oracle-known-on-main.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index 6ee6bd56ed53..8e010c916500 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -152,8 +152,8 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith return { moved: true } } - // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. The tab bar and the - // rows follow the groups, so every saved copy of the tab order agrees. + // 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[], diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index cff86d2bedee..7bfd83a2251e 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -118,6 +118,15 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ step: 'rename after restart', cause: 'orcad title after restart' }, + // 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) => ({ From 13e6a9ec6e28b953059dd4626f75d4f600499211 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 10 Oct 2026 19:40:10 -0400 Subject: [PATCH 3/4] 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 --- ...e-runtime-owned-mobile-session-terminal.ts | 1 + ...ca-runtime-create-terminal-dependencies.ts | 2 +- .../runtime/orca-runtime-create-terminal.ts | 2 +- ...le-session-tabs-cold-serve-hydrate.spec.ts | 51 +++++++++++++++++++ src/main/runtime/runtime-pane-admission.ts | 6 ++- .../runtime/runtime-terminal-contracts.ts | 2 + .../runtime-terminal-spawn-placement.ts | 11 ++++ .../workspace-layout-oracle-headless.spec.ts | 41 ++++++++++++++- .../workspace-layout-oracle-known-on-main.ts | 15 +++++- 9 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index 32533b3f9ecc..28bf9a4b87f4 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -110,6 +110,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 ? { diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 412abf41939c..a61e7aebb862 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -27,7 +27,7 @@ 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' diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index eace2407b86f..92a0eb8e6fa1 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - placement: dependencies.runtimeNewTabPlacement(launchOpts.viewMode), + ...dependencies.newTabIn(launchOpts, this.mobileSessionTabsByWorktree, workspace.id), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts index 612efbb77b82..8bde9bb2ed5e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -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, which also focuses it; the CLI create follows focus. + const phone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + targetGroupId: 'group-right' + }) + const cli = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`) + const created = [phone.tab.parentTabId, cli.tabId] + const rightGroupOf = (session: WorkspaceSessionState) => + session.tabGroups?.[TEST_WORKTREE_ID]?.find((group) => group.id === 'group-right')?.tabOrder + expect(rightGroupOf(getSession())).toEqual(expect.arrayContaining(created)) + + 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'], + ['daemon-tab', ...created] + ]) + }) }) diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index 624625283842..16ca7e224059 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -8,6 +8,8 @@ type AdmittedSpawnArgs = Parameters[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. */ @@ -30,6 +32,7 @@ function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission { workspace, tabId, leafId, + ...(args.groupId ? { groupId: args.groupId } : {}), ...(viewMode ? { viewMode } : {}), ...(args.cwd ? { creation: { startupCwd: args.cwd } } : {}) } @@ -65,9 +68,10 @@ export async function spawnInAdmittedPane( // 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> try { - result = await controller.spawn(args) + result = await controller.spawn(spawnArgs) } catch (error) { if (outcome === 'admitted') { await withdrawRuntimePane(store, args) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 43b487db3c94..ba8fc218728f 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -67,6 +67,8 @@ export type TerminalCreateOptions = { terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' + /** The tab group a background create joins; defaults to the host's focused group. */ + targetGroupId?: string startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] /** The surface that asked for this `startupAgent` launch; the runtime attributes every one it diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index 29d51eec5da3..a3dc124b34ad 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -1,3 +1,4 @@ +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-session-contracts' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' @@ -6,6 +7,16 @@ export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): Terminal return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' } } +/** A runtime-started tab opens in the group asked for, else the group focused on the host. */ +export function newTabIn( + opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }, + snapshots: ReadonlyMap, + worktreeId: string +): { placement: TerminalPanePlacement; groupId?: string } { + const groupId = opts.targetGroupId ?? snapshots.get(worktreeId)?.activeGroupId + return { placement: runtimeNewTabPlacement(opts.viewMode), ...(groupId ? { groupId } : {}) } +} + export function runtimeSplitPlacement( parentLeafId: string, direction: TerminalPaneSplitDirection diff --git a/tests/e2e/workspace-layout-oracle-headless.spec.ts b/tests/e2e/workspace-layout-oracle-headless.spec.ts index c97d1af77a68..9d03d6233e2d 100644 --- a/tests/e2e/workspace-layout-oracle-headless.spec.ts +++ b/tests/e2e/workspace-layout-oracle-headless.spec.ts @@ -97,10 +97,10 @@ async function handleForTab(run: Run, tabId: string): Promise { return handle! } -async function createTab(run: Run, client: RuntimeClient): Promise { +async function createTab(run: Run, client: RuntimeClient, targetGroupId?: string): Promise { 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,43 @@ const SCENARIOS: Scenario[] = [ await run.restart('warm restart', [1, 1]) } }, + { + // #27083 review: a phone create joins the group it targets, and a CLI create the focused one, + // 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}`] + ) + // The phone create focused the second group, so a CLI create joins it too. + const cli = (await createTerminal(run)).tabId! + await run.check('CLI create in focused group', [1, 1, 1, 1]) + await run.restart('cold restart', [1, 1, 1, 1], true) + const after = await membership() + const expected = JSON.stringify([[first], [second, phone, cli].toSorted()]) + run.expectNone( + 'created tabs stay in the second group 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', diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index 7bfd83a2251e..d2f9f63e3f6e 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -135,7 +135,20 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ step: 'move', detail: /^group \S+ selects /, cause: 'headless move-to-group keeps the target group selection' - })) + })), + // A CLI create on a split headless host: the live publication puts a new active tab in groups[0] + // (mergeMobileSessionTabGroups / distributeHeadlessTabsAcrossGroups fallback), while the stored + // pane defaults to the focused group, so clients see it move groups on a restart, as on main. + // Fixed in PR 6, where the published projection replaces those snapshot builders. + ...(['orcad', 'electron'] as const).flatMap((kind) => + ['CLI create in focused group', 'before cold restart'].map((step) => ({ + scenario: `headless-${kind}-create-in-second-group-cold-restart`, + check: 'client' as const, + step, + detail: /^group \S+: runtime order /, + cause: 'headless CLI create publishes into the first group' + })) + ) ] function covers( From 0c48d147bcfa924ece8ee511c73487fb5e9aa50e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 10 Oct 2026 20:43:09 -0400 Subject: [PATCH 4/4] 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 --- .../runtime/orca-runtime-create-terminal.ts | 2 +- ...le-session-tabs-cold-serve-hydrate.spec.ts | 14 +++++++------- .../runtime/runtime-terminal-contracts.ts | 2 +- .../runtime-terminal-spawn-placement.ts | 19 ++++++++++--------- .../workspace-layout-oracle-headless.spec.ts | 11 +++++------ .../workspace-layout-oracle-known-on-main.ts | 15 +-------------- 6 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 92a0eb8e6fa1..02e05bb1c89b 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - ...dependencies.newTabIn(launchOpts, this.mobileSessionTabsByWorktree, workspace.id), + ...dependencies.newTabIn(launchOpts), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts index 8bde9bb2ed5e..3b4c48eeddb7 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec.ts @@ -208,23 +208,23 @@ describe('OrcaRuntimeService', () => { runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) - // The phone targets the right group, which also focuses it; the CLI create follows focus. + // 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 created = [phone.tab.parentTabId, cli.tabId] - const rightGroupOf = (session: WorkspaceSessionState) => - session.tabGroups?.[TEST_WORKTREE_ID]?.find((group) => group.id === 'group-right')?.tabOrder - expect(rightGroupOf(getSession())).toEqual(expect.arrayContaining(created)) + 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'], - ['daemon-tab', ...created] + ['serve-tab', cli.tabId], + ['daemon-tab', phone.tab.parentTabId] ]) }) }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index ba8fc218728f..ae6ee097d187 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -67,7 +67,7 @@ export type TerminalCreateOptions = { terminalKittyKeyboardProtocol?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' - /** The tab group a background create joins; defaults to the host's focused group. */ + /** The tab group a background create joins; defaults to the first group. */ targetGroupId?: string startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] diff --git a/src/main/runtime/runtime-terminal-spawn-placement.ts b/src/main/runtime/runtime-terminal-spawn-placement.ts index a3dc124b34ad..3141cdf813b9 100644 --- a/src/main/runtime/runtime-terminal-spawn-placement.ts +++ b/src/main/runtime/runtime-terminal-spawn-placement.ts @@ -1,4 +1,3 @@ -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-session-contracts' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types' @@ -7,14 +6,16 @@ export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): Terminal return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' } } -/** A runtime-started tab opens in the group asked for, else the group focused on the host. */ -export function newTabIn( - opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }, - snapshots: ReadonlyMap, - worktreeId: string -): { placement: TerminalPanePlacement; groupId?: string } { - const groupId = opts.targetGroupId ?? snapshots.get(worktreeId)?.activeGroupId - return { placement: runtimeNewTabPlacement(opts.viewMode), ...(groupId ? { groupId } : {}) } +/** 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( diff --git a/tests/e2e/workspace-layout-oracle-headless.spec.ts b/tests/e2e/workspace-layout-oracle-headless.spec.ts index 9d03d6233e2d..9d54de0a1c27 100644 --- a/tests/e2e/workspace-layout-oracle-headless.spec.ts +++ b/tests/e2e/workspace-layout-oracle-headless.spec.ts @@ -305,8 +305,8 @@ const SCENARIOS: Scenario[] = [ } }, { - // #27083 review: a phone create joins the group it targets, and a CLI create the focused one, - // across a cold restart. + // #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) @@ -329,14 +329,13 @@ const SCENARIOS: Scenario[] = [ 'phone create joins the second group', joined === phoneExpected ? [] : [`groups ${joined}, expected ${phoneExpected}`] ) - // The phone create focused the second group, so a CLI create joins it too. const cli = (await createTerminal(run)).tabId! - await run.check('CLI create in focused group', [1, 1, 1, 1]) + 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], [second, phone, cli].toSorted()]) + const expected = JSON.stringify([[first, cli].toSorted(), [second, phone].toSorted()]) run.expectNone( - 'created tabs stay in the second group after a cold restart', + 'created tabs stay in their groups after a cold restart', after === expected ? [] : [`groups ${after}, expected ${expected}`] ) } diff --git a/tests/e2e/workspace-layout-oracle-known-on-main.ts b/tests/e2e/workspace-layout-oracle-known-on-main.ts index d2f9f63e3f6e..7bfd83a2251e 100644 --- a/tests/e2e/workspace-layout-oracle-known-on-main.ts +++ b/tests/e2e/workspace-layout-oracle-known-on-main.ts @@ -135,20 +135,7 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [ step: 'move', detail: /^group \S+ selects /, cause: 'headless move-to-group keeps the target group selection' - })), - // A CLI create on a split headless host: the live publication puts a new active tab in groups[0] - // (mergeMobileSessionTabGroups / distributeHeadlessTabsAcrossGroups fallback), while the stored - // pane defaults to the focused group, so clients see it move groups on a restart, as on main. - // Fixed in PR 6, where the published projection replaces those snapshot builders. - ...(['orcad', 'electron'] as const).flatMap((kind) => - ['CLI create in focused group', 'before cold restart'].map((step) => ({ - scenario: `headless-${kind}-create-in-second-group-cold-restart`, - check: 'client' as const, - step, - detail: /^group \S+: runtime order /, - cause: 'headless CLI create publishes into the first group' - })) - ) + })) ] function covers(