diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index d9cded01517..9ec21670ec0 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -1,4 +1,5 @@ import { defineMethod } from '../../core' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import { remoteFileContentBudget } from '../files-remote-content-budget' import { remoteRpcResultExceedsContentBudget } from '../../../../../shared/remote-rpc-content-budget' import { @@ -19,12 +20,18 @@ export const TERMINAL_QUERY_METHODS = [ name: 'terminal.list', permission: 'workspace', params: TerminalListParams, - handler: async (params, { runtime }) => - runtime.listTerminals(params.worktree, params.limit, { + handler: async (params, { runtime }) => { + // Switch point: main places the swept workspace's live terminals itself, so this listing + // names their panes and the window's sweep only reads it. + if (ORCA_LAYOUT_OWNER_IS_MAIN && params.placeUnshownLiveTerminals && params.worktree) { + await runtime.placeUnshownLiveTerminalsOf(params.worktree) + } + return runtime.listTerminals(params.worktree, params.limit, { handles: params.handles, requireFreshPtyLiveness: params.requireFreshPtyLiveness, includeVisualLayouts: params.includeVisualLayouts }) + } }), defineMethod({ name: 'terminal.resolveActive', diff --git a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts index 19cc2455ab7..645f5c1f87e 100644 --- a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts +++ b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.ts @@ -3,7 +3,7 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { worktreeIdentityKey } from '../../../../shared/worktree/id' +import { worktreeIdentityKey, worktreeIdsEqual } from '../../../../shared/worktree/id' import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { getSshConnectionManager } from '../../../ssh/targets/ssh-target-registry' @@ -29,6 +29,7 @@ import { panesBoundToGoneLocalTerminals } from '../../terminal/recovery/foreign-pane-bindings' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getExplicitWorktreeIdSelector } from '../../runtime-worktree-selection' /** The longest a workspace's first surface waits for main's placement pass. */ export const FIRST_SURFACE_PLACEMENT_WAIT_MS = 500 @@ -39,31 +40,41 @@ export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRunti protected abstract notePaneSleeping(paneKey: string): void private placementRun: Promise | null = null - private placementAgain = false - /** The one pass running now, without the passes queued behind it. */ - private placementPass: Promise | null = null + /** What the next pass covers: every workspace, or the ones a window's sweep asked for. */ + private placementQueued: 'all' | Set | null = null + /** The one pass running now, without the passes queued behind it, and what it covers. */ + private placementPass: { + run: Promise + scope: 'all' | ReadonlySet + } | null = null private stopFollowingProviderRebinds: (() => void) | null = null /** - * Layout-owner build: shows every live local terminal no pane shows. Runs at startup, window - * load and client attach, and after the terminal service is replaced. One pass at a time: a call - * during a pass runs one more pass after it, so a terminal seen late is still placed. + * Layout-owner build: shows every live local terminal no pane shows, or only `worktreeId`'s. + * Runs at startup, window load and client attach, after the terminal service is replaced, and + * for one workspace on each window sweep. One pass at a time: a call during a pass runs one + * more pass after it, so a terminal seen late is still placed. */ - placeUnshownLiveTerminals(): Promise { + placeUnshownLiveTerminals(worktreeId?: string): Promise { this.stopFollowingProviderRebinds ??= onLocalPtyProviderRebound( () => void placeUnshownLiveTerminalsBestEffort(this) ) + const queued = this.placementQueued + this.placementQueued = + worktreeId === undefined || queued === 'all' + ? 'all' + : new Set([...(queued ?? []), worktreeId]) if (this.placementRun) { - this.placementAgain = true return this.placementRun } const run = async (): Promise => { let placement: LiveTerminalPlacement do { - this.placementAgain = false - this.placementPass = this.placeUnshownLiveTerminalsOnce() - placement = await this.placementPass - } while (this.placementAgain) + const scope = this.placementQueued ?? 'all' + this.placementQueued = null + this.placementPass = { run: this.placeUnshownLiveTerminalsOnce(scope), scope } + placement = await this.placementPass.run + } while (this.placementQueued) return placement } this.placementRun = run().finally(() => { @@ -73,6 +84,22 @@ export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRunti return this.placementRun } + /** + * Layout-owner build: a window's sweep (first visit, an empty or sleeping activation, a phone + * wake) shows that workspace's unshown live terminals before it reads which ones are shown. + */ + async placeUnshownLiveTerminalsOf(worktreeSelector: string): Promise { + const worktreeId = getExplicitWorktreeIdSelector(worktreeSelector) + if (!ORCA_LAYOUT_OWNER_IS_MAIN || !worktreeId) { + return + } + try { + await this.placeUnshownLiveTerminals(worktreeId) + } catch (error) { + console.warn('[layout] showing live terminals failed:', error) + } + } + /** * A workspace's first surface waits out the placement pass running when it arrives, not the * passes queued behind it: a live terminal that pass is placing is the workspace's tab, not room @@ -85,18 +112,33 @@ export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRunti if (!ORCA_LAYOUT_OWNER_IS_MAIN || !('initial' in command) || !command.initial || !pass) { return null } + // A sweep's pass places only its own workspace's terminals: another workspace waits for none. + const { scope } = pass + if ( + scope !== 'all' && + ![...scope].some((worktreeId) => worktreeIdsEqual(worktreeId, command.workspace)) + ) { + return null + } let timer: NodeJS.Timeout | undefined const cap = new Promise((resolve) => { timer = setTimeout(resolve, FIRST_SURFACE_PLACEMENT_WAIT_MS) }) - return Promise.race([pass.catch(() => {}), cap]).finally(() => clearTimeout(timer)) + return Promise.race([pass.run.catch(() => {}), cap]).finally(() => clearTimeout(timer)) } - private async placeUnshownLiveTerminalsOnce(): Promise { - await this.unbindPanesFromOtherWorkspacesTerminals() + private async placeUnshownLiveTerminalsOnce( + scope: 'all' | ReadonlySet + ): Promise { + // A sweep's pass leaves other workspaces, and their panes, as they are. + if (scope === 'all') { + await this.unbindPanesFromOtherWorkspacesTerminals() + } // A host that keeps no local terminals across restarts has none to find. if (!this.canRecoverPersistentLocalPtysFn()) { - this.markDeadBoundPanesSleeping() + if (scope === 'all') { + this.markDeadBoundPanesSleeping() + } return { placed: [], declined: [] } } let censusRead = false @@ -119,9 +161,16 @@ export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRunti censusRead = inventory !== null return inventory && { ...inventory, workspaceIds } }, - ptys: () => this.ptysById.values(), - // A window spawn holds its workspace's lock until its pane is recorded (spawn-run.ts). + ptys: () => + scope === 'all' + ? this.ptysById.values() + : [...this.ptysById.values()].filter((pty) => + [...scope].some((worktreeId) => worktreeIdsEqual(worktreeId, pty.worktreeId)) + ), + // A window spawn holds its workspace's lock until its pane is recorded (spawn-run.ts), and a + // registration in flight is binding one, as a census reads it (pty-terminal-summary.ts). isStillBinding: (pty) => + this.pendingPtyRegistrationIncarnations.has(pty.ptyId) || this.terminalMutationLock.hasActiveSpawns(worktreeIdentityKey(pty.worktreeId)), hostIds: () => this.store?.getWorkspaceSessionHostIds?.() ?? [], session: (hostId) => this.store?.getWorkspaceSession?.(hostId), @@ -136,7 +185,7 @@ export abstract class OrcaRuntimeWithRestoredTerminalPlacement extends OrcaRunti } }) // Only a census that answered proves a terminal gone; an unread one leaves panes as they are. - if (censusRead) { + if (censusRead && scope === 'all') { this.markDeadBoundPanesSleeping() } return placement diff --git a/src/main/runtime/workspaces/persisted-surfaces/sweep-terminal-placement.test.ts b/src/main/runtime/workspaces/persisted-surfaces/sweep-terminal-placement.test.ts new file mode 100644 index 00000000000..f3747f320d1 --- /dev/null +++ b/src/main/runtime/workspaces/persisted-surfaces/sweep-terminal-placement.test.ts @@ -0,0 +1,246 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../../../persistence/loading-store/store' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore, + reopenTopologyStore +} from '../../../persistence/terminal-topology/terminal-topology-profile-fixture' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' +import { TERMINAL_QUERY_METHODS } from '../../rpc/methods/terminal/terminal-query-methods' +import { FIRST_SURFACE_PLACEMENT_WAIT_MS } from './restored-terminal-placement' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('../../../telemetry/client', () => ({ track: vi.fn() })) + +// Layout-owner build: a window's sweep (first visit, an empty or sleeping activation, a phone +// wake) asks main to show the swept workspace's unshown live terminals before it reads the host's +// listing, so main, not the window, gives each one its tab. + +const SWEPT = FIXTURE_GIT_WORKTREE_ID +const OTHER = 'repo-local::/fixture/other' +const directories: string[] = [] + +type LiveSession = { + id: string + cwd: string + title: string + worktreeId: string + incarnationId?: string + exiting?: true +} + +class SweepRuntime extends OrcaRuntimeService { + protected override async getResolvedWorktreeMap(): Promise> { + return new Map( + [SWEPT, OTHER].flatMap((id) => { + const worktree = this.buildResolvedWorktreeFromId(id) + return worktree ? [[id, worktree] as const] : [] + }) + ) + } + + ptyRecord(ptyId: string) { + return this.ptysById.get(ptyId) + } +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function session(id: string, worktreeId = SWEPT): LiveSession { + return { id, cwd: '/fixture/local', title: 'zsh', worktreeId, incarnationId: `${id}-inc` } +} + +function runtimeOn(store: Store, live: LiveSession[]): SweepRuntime { + const runtime = new SweepRuntime(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [...live], + listProcessesWithHostScope: async () => ({ processes: [...live], hostIds: ['local' as const] }) + }) + return runtime +} + +async function hostWith(live: LiveSession[]) { + const directory = mkdtempSync(join(tmpdir(), 'orca-sweep-placement-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + return { store, directory, runtime: runtimeOn(store, live) } +} + +function boundPtyIds(store: Store, worktreeId: string): string[] { + const session = store.getWorkspaceSession() + return (session.tabsByWorktree[worktreeId] ?? []).flatMap((tab) => + Object.values(session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ) +} + +function terminalListHandler() { + const method = TERMINAL_QUERY_METHODS.find((candidate) => candidate.name === 'terminal.list') + if (!method) { + throw new Error('terminal.list is not defined') + } + return method.handler +} + +describe('main places the swept workspace’s live terminals, layout-owner build', () => { + it('shows the swept workspace’s unshown terminal in a tab, and leaves another workspace’s', async () => { + const { store, runtime } = await hostWith([session('pty-swept'), session('pty-other', OTHER)]) + + await runtime.placeUnshownLiveTerminalsOf(`id:${SWEPT}`) + + expect(boundPtyIds(store, SWEPT)).toEqual(['pty-swept']) + expect(boundPtyIds(store, OTHER)).toEqual([]) + }) + + it('the sweep’s listing places before it lists, so it names the placed terminal’s pane', async () => { + const { runtime } = await hostWith([session('pty-swept')]) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only `runtime` from its context. + const context = { runtime } as never + const listing = await terminalListHandler()( + { + worktree: `id:${SWEPT}`, + limit: 200, + requireFreshPtyLiveness: true, + includeVisualLayouts: false, + placeUnshownLiveTerminals: true + }, + context + ) + + const listed = listing.terminals.find((terminal) => terminal.ptyId === 'pty-swept') + expect(listed).toMatchObject({ orphaned: false }) + expect(listed?.tabId).not.toMatch(/^pty:/) + }) + + it('a listing that does not ask places nothing', async () => { + const { store, runtime } = await hostWith([session('pty-swept')]) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only `runtime` from its context. + const context = { runtime } as never + await terminalListHandler()( + { worktree: `id:${SWEPT}`, limit: 200, requireFreshPtyLiveness: true }, + context + ) + + expect(boundPtyIds(store, SWEPT)).toEqual([]) + }) + + it('leaves a terminal whose registration is still binding its pane, as a census does', async () => { + const { store, runtime } = await hostWith([session('pty-registering')]) + runtime.beginPtyRegistration('pty-registering', 'pty-registering-inc') + + await runtime.placeUnshownLiveTerminals() + + expect(boundPtyIds(store, SWEPT)).toEqual([]) + }) + + it('never brings back a closed tab’s terminal its host is killing', async () => { + const { store, runtime } = await hostWith([{ ...session('pty-closing'), exiting: true }]) + + await runtime.placeUnshownLiveTerminals() + + expect(boundPtyIds(store, SWEPT)).toEqual([]) + }) +}) + +describe('a terminal created by the CLI, across a restart, layout-owner build', () => { + it('is left alone while its run owns it, and after a restart is shown like any other', async () => { + const live = [session('pty-cli')] + const { store, directory, runtime } = await hostWith(live) + // The census records it; the CLI's create marks it owned by this run (terminal-create.ts). + await runtime.listTerminals(`id:${SWEPT}`) + runtime.ptyRecord('pty-cli')!.runtimeSessionOwned = true + + await runtime.placeUnshownLiveTerminals() + expect(boundPtyIds(store, SWEPT)).toEqual([]) + + // Quit and relaunch: ownership by the old run is not saved, so the window-owned build's sweep + // would give the live terminal no pane shows a new tab, and so does main. + const relaunched = await reopenTopologyStore(store, directory) + const restarted = runtimeOn(relaunched, live) + await restarted.placeUnshownLiveTerminals() + + expect(restarted.ptyRecord('pty-cli')?.runtimeSessionOwned).toBe(false) + expect(boundPtyIds(relaunched, SWEPT)).toEqual(['pty-cli']) + }) + + it('one whose pane was saved stays in that pane after a restart, with no second tab', async () => { + const live = [session('pty-cli')] + const { store, directory, runtime } = await hostWith(live) + await runtime.placeUnshownLiveTerminals() + expect(boundPtyIds(store, SWEPT)).toEqual(['pty-cli']) + + const relaunched = await reopenTopologyStore(store, directory) + await runtimeOn(relaunched, live).placeUnshownLiveTerminals() + + expect(boundPtyIds(relaunched, SWEPT)).toEqual(['pty-cli']) + }) +}) + +describe('a workspace’s first surface beside a sweep’s pass, layout-owner build', () => { + async function hostReadingASweepCensus() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sweep-first-surface-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + let answerCensus = (): void => {} + const census = new Promise((resolve) => { + answerCensus = () => resolve([session('pty-other', OTHER)]) + }) + const listProcesses = vi.fn(() => census) + const runtime = new SweepRuntime(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses + }) + const sweeping = runtime.placeUnshownLiveTerminalsOf(`id:${OTHER}`) + await vi.waitFor(() => expect(listProcesses).toHaveBeenCalled()) + const firstSurfaceOf = (workspace: string) => + runtime.applyLayoutCommand({ + type: 'createTerminalTab', + workspace, + tabId: '33333333-3333-4333-8333-333333333333', + leafId: '44444444-4444-4444-8444-444444444444', + initial: 'activation' + }) + return { answerCensus, sweeping, firstSurfaceOf } + } + + it('does not wait for a sweep of another workspace, which cannot place its terminals', async () => { + const { answerCensus, sweeping, firstSurfaceOf } = await hostReadingASweepCensus() + + const startedAt = performance.now() + const reply = await firstSurfaceOf(SWEPT) + + expect(performance.now() - startedAt).toBeLessThan(FIRST_SURFACE_PLACEMENT_WAIT_MS - 100) + expect(reply.ok).toBe(true) + answerCensus() + await sweeping + }) + + it('waits, up to the cap, for a sweep of its own workspace', async () => { + const { answerCensus, sweeping, firstSurfaceOf } = await hostReadingASweepCensus() + + const startedAt = performance.now() + await firstSurfaceOf(OTHER) + + expect(performance.now() - startedAt).toBeGreaterThanOrEqual( + FIRST_SURFACE_PLACEMENT_WAIT_MS - 20 + ) + answerCensus() + await sweeping + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate-main-placement.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate-main-placement.test.ts new file mode 100644 index 00000000000..a4e0f749164 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate-main-placement.test.ts @@ -0,0 +1,123 @@ +// Layout-owner build: the window's activation sweep asks main to place the workspace's unshown +// live terminals as part of its listing, then only reads the answer. A pane main names is the +// terminal's surface (main's frames draw it, maybe after the answer arrives), so the sweep writes +// nothing and its `surfaced` verdict still decides between resuming agents and seeding a shell. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { provideLayoutFrameTerminalPanes } from '@/runtime/layout-frame-terminal-panes' +import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' +import { + type GateTestStore, + listed, + LIVE_LEAF_ID, + seedExistingSurface, + SIBLING_LEAF_ID, + sleepingRecord, + testDeps, + WORKTREE_ID +} from './worktree-agent-activation-gate.test-fixture' +import { + readWorktreeLiveTerminalSurfaceOwners, + type LiveTerminalSurfaceOwner +} from './worktree-live-terminal-surface-owners' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const LIVE_PTY = `${WORKTREE_ID}@@live-pty` +const MAIN_TAB = '3359f7c8-9bd8-4931-8104-52b6bdbd108d' + +/** The pane main's listing names for a terminal it just placed. */ +function placedByMain(ptyId: string, tabId = MAIN_TAB): LiveTerminalSurfaceOwner { + return { paneKey: `${tabId}:${LIVE_LEAF_ID}`, ptyId, tabId } +} + +function expectNoWindowWrite(store: GateTestStore): void { + expect(store.createTab).not.toHaveBeenCalled() + expect(store.setTabLayout).not.toHaveBeenCalled() + expect(store.updateTabPtyId).not.toHaveBeenCalled() + expect(store.replaceTerminalLayoutPanePtyId).not.toHaveBeenCalled() +} + +beforeEach(() => { + // Main's frames have not reached this window yet. + provideLayoutFrameTerminalPanes({ holdsTerminalPane: () => false, terminalTab: () => undefined }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('the sweep’s listing, layout-owner build', () => { + it('asks main to place the workspace’s unshown live terminals before it lists', async () => { + const call = vi.fn(async () => ({ + ok: true, + result: { terminals: [], hostScope: { hostIds: ['local'], omittedHostIds: [] } } + })) + vi.stubGlobal('window', { api: { runtime: { call } } }) + + await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID) + + expect(call).toHaveBeenCalledWith({ + method: 'terminal.list', + params: expect.objectContaining({ + worktree: `id:${WORKTREE_ID}`, + requireFreshPtyLiveness: true, + placeUnshownLiveTerminals: true + }) + }) + }) +}) + +describe('the activation sweep reads main’s placement, layout-owner build', () => { + it('counts a terminal main placed as shown before its frame arrives: no shell is seeded', async () => { + const { deps, resume } = testDeps({ + sessions: [listed(LIVE_PTY)], + surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY)]]) + }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + + expectNoWindowWrite(deps.getState()) + expect(resume).not.toHaveBeenCalled() + }) + + it('writes nothing for a pane main names in a tab this window already has', async () => { + const { deps } = testDeps({ + sessions: [listed(LIVE_PTY)], + surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY, 'tab-known')]]) + }) + seedExistingSurface(deps.getState(), { tabId: 'tab-known', leafId: LIVE_LEAF_ID }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + + expectNoWindowWrite(deps.getState()) + }) + + it('still resumes the workspace’s sleeping agent beside the terminal main placed', async () => { + const { deps, resume } = testDeps({ + sessions: [{ ...listed(LIVE_PTY), title: 'zsh', agentOwnership: 'absent' }], + surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY)]]), + sleeping: [sleepingRecord('tab-slept', SIBLING_LEAF_ID, 'slept-session')] + }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledOnce() + expectNoWindowWrite(deps.getState()) + }) + + it('seeds no shell when nothing resumes beside the terminal main placed', async () => { + const { deps, resume } = testDeps({ + sessions: [{ ...listed(LIVE_PTY), title: 'zsh', agentOwnership: 'absent' }], + surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY)]]), + sleeping: [sleepingRecord('tab-slept', SIBLING_LEAF_ID, 'slept-session')], + resumeCount: 0 + }) + + // 'empty' would tell the caller to seed a fresh shell beside the live one. + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + + expect(resume).toHaveBeenCalledOnce() + expectNoWindowWrite(deps.getState()) + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index b5ca49e768b..b7d908c63a1 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -21,6 +21,13 @@ import { indexLiveTerminalSurfaceOwners } from './worktree-live-terminal-surface // Main places live terminals in a layout-owner build; those tests' twins are in // worktree-agent-activation-gate-layout-owner.test.ts, under the same name unless noted. const windowOwnedIt = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN) + +/** Layout-owner build: the sweep only reads main's listing; it binds no pane of its own. */ +function expectNoBindingWrite(store: GateTestStore): void { + expect(store.setTabLayout).not.toHaveBeenCalled() + expect(store.updateTabPtyId).not.toHaveBeenCalled() + expect(store.replaceTerminalLayoutPanePtyId).not.toHaveBeenCalled() +} const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale' function runtimeSnapshot( @@ -482,6 +489,11 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main's listing names the pane it holds and its frames rebind it; the window writes nothing. + expectNoBindingWrite(store) + return + } expect(store.updateTabPtyId).toHaveBeenCalledWith('tab-live', livePtyId) expect(store.replaceTerminalLayoutPanePtyId).toHaveBeenCalledWith( 'tab-live', @@ -563,6 +575,15 @@ describe('worktree agent activation gate', () => { }) seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main holds the layout: the leaf it names is shown by its frames, so nothing is declined. + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + expect(createTab).not.toHaveBeenCalled() + expectNoBindingWrite(deps.getState()) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + return + } // The seam re-checks its own guard, so an existing tab is not re-seeded by 'empty'. await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty') @@ -610,9 +631,10 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() expect(resume).not.toHaveBeenCalled() - expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: livePtyId - }) + expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual( + // Layout-owner build: main's frames bind the pane, not the window. + ORCA_LAYOUT_OWNER_IS_MAIN ? {} : { [LIVE_LEAF_ID]: livePtyId } + ) } ) @@ -633,9 +655,10 @@ describe('worktree agent activation gate', () => { await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') expect(createTab).not.toHaveBeenCalled() - expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({ - [LIVE_LEAF_ID]: livePtyId - }) + expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual( + // Layout-owner build: main's frames bind the pane, not the window. + ORCA_LAYOUT_OWNER_IS_MAIN ? {} : { [LIVE_LEAF_ID]: livePtyId } + ) }) it('re-reads local ownership after the census before minting', async () => { diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts index a390ea68cb0..e7310446090 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts @@ -142,11 +142,13 @@ describe('adopting live terminals in a layout-owner build', () => { ).resolves.toEqual({ surfaced: true, declinedPtyIds: [] }) }) + // Main placed the terminals while answering the sweep's listing, so the listing is fresher than + // any frame this window has drawn: it is the verdict, and the frames catch up. it.each([ ['binds to another terminal', [`${GIT_KEY}@@aaaa0008`, `${GIT_KEY}@@aaaa0009`]], ['leaves unbound', [undefined, undefined]] ] satisfies [string, [string?, string?]][])( - 'declines a terminal whose pane main %s', + 'counts the pane main’s listing names, though the last frame drawn %s, and writes nothing', async (_, ptyIds) => { const { window: windowSession, main } = sessions(ptyIds) const store = createTestStore() @@ -155,14 +157,18 @@ describe('adopting live terminals in a layout-owner build', () => { provideLayoutFrameTerminalPanes(sync) frames.onFrame!(snapshotOf(main)) await flushMicrotasks() + const createTab = vi.spyOn(store.getState(), 'createTab') + const setTabLayout = vi.spyOn(store.getState(), 'setTabLayout') await expect( adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY], async () => owners) - ).resolves.toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY] }) + ).resolves.toEqual({ surfaced: true, declinedPtyIds: [] }) + expect(createTab).not.toHaveBeenCalled() + expect(setTabLayout).not.toHaveBeenCalled() } ) - it('declines a terminal whose tab main does not hold, rather than making one', async () => { + it('counts a tab main’s listing names before its frame arrives, rather than making one', async () => { const { window: windowSession } = sessions() const store = createTestStore() await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }]) @@ -180,11 +186,12 @@ describe('adopting live terminals in a layout-owner build', () => { async () => owners ) - expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY] }) + expect(adoption).toEqual({ surfaced: true, declinedPtyIds: [] }) expect(createTab).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toEqual(['tab-other']) }) - it('declines a terminal, then the snapshot arriving after the sweep draws its tab with no new sweep', async () => { + it('counts a terminal shown, then the snapshot arriving after the sweep draws its tab with no new sweep', async () => { const { window: windowSession, main } = sessions() const store = createTestStore() await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }]) @@ -196,7 +203,7 @@ describe('adopting live terminals in a layout-owner build', () => { await expect( adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY, RIGHT_PTY], readOwners) - ).resolves.toEqual({ surfaced: false, declinedPtyIds: [LEFT_PTY, RIGHT_PTY] }) + ).resolves.toEqual({ surfaced: true, declinedPtyIds: [] }) frames.onFrame!(snapshotOf(main)) await flushMicrotasks() diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index 626f712cd46..4dd94fd4610 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -113,8 +113,8 @@ function adoptHostOwnedSurface( ): boolean { const store = getState() const known = tabExists(store, owner.tabId) - // Switch point: main's frames draw a pane main holds for this terminal; the window makes no tab - // or pane of its own. Declined when main lacks it (main-first follow-up) or before the snapshot. + // Switch point, a paired server's workspace (the gate blocks those before any sweep today; a + // local one returns before this): frames draw the server's pane; the window makes none. if (ORCA_LAYOUT_OWNER_IS_MAIN && !known) { return framesShowRecordedPane(worktreeId, owner) } @@ -215,6 +215,15 @@ export async function adoptLiveWorkspacePtySurfaces( declinedPtyIds.push(ptyId) continue } + const mainPlaces = + ORCA_LAYOUT_OWNER_IS_MAIN && runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) === null + // Switch point: main placed the workspace's unshown terminals before answering (the listing's + // placeUnshownLiveTerminals), so a pane main names is the surface and main's frames draw it. + // The window writes nothing. A paired server's workspace keeps today's path. + if (mainPlaces && !('unowned' in owner)) { + surfaced = true + continue + } if (!('unowned' in owner)) { if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds)) { surfaced = true @@ -223,13 +232,9 @@ export async function adoptLiveWorkspacePtySurfaces( } continue } - // Switch point: main shows every live terminal no pane shows at startup and window load - // (placeUnshownLiveTerminals) and records the pane it chose, so the window only reads that - // the frames show it there. A paired server's workspace keeps today's path. - if ( - ORCA_LAYOUT_OWNER_IS_MAIN && - runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) === null - ) { + // Switch point: one main could not place stays unshown, unless main's frames already show it + // in the pane main recorded (main's graph not yet reinstalled when it answered). + if (mainPlaces) { if (owner.recorded && mainHoldsRecordedPane(worktreeId, owner.recorded)) { surfaced = true } else { diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts index 4fb9cfcd97b..f2ba0d289a7 100644 --- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts +++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts @@ -1,3 +1,4 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' import { @@ -223,7 +224,9 @@ describe('live terminal surface owners', () => { worktree: `id:${WORKTREE_ID}`, limit: 200, requireFreshPtyLiveness: true, - includeVisualLayouts: false + includeVisualLayouts: false, + // Layout-owner build: main places the workspace's unshown live terminals first. + ...(ORCA_LAYOUT_OWNER_IS_MAIN ? { placeUnshownLiveTerminals: true } : {}) } }) }) diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts index f84a3c2efa1..508a4b9d1d8 100644 --- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts +++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts @@ -6,6 +6,7 @@ import type { } from '../../../shared/runtime-types' import { worktreeIdsEqual } from '../../../shared/worktree/id' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' /** The exact surface the execution host records as owning a live PTY. */ export type LiveTerminalSurfaceOwner = { @@ -124,7 +125,10 @@ export async function readWorktreeLiveTerminalSurfaceOwners( worktree: toRuntimeWorktreeSelector(worktreeId), limit: OWNER_LISTING_LIMIT, requireFreshPtyLiveness: true, - includeVisualLayouts: false + includeVisualLayouts: false, + // Switch point: main shows the workspace's unshown live terminals first, so the listing + // names main's panes for them and this sweep only reads it. + ...(ORCA_LAYOUT_OWNER_IS_MAIN ? { placeUnshownLiveTerminals: true } : {}) } }) if (!response.ok || !isScopedTerminalListResult(response.result)) { diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index 60030bdb637..a4e88bc1bf9 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -71,7 +71,10 @@ export const TerminalListParams = z.object({ requireFreshPtyLiveness: z.boolean().optional(), // Why: layouts are ~31% of a large listing and only the human CLI formatter // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() + includeVisualLayouts: z.boolean().optional(), + // Layout-owner hosts show the workspace's unshown live terminals before listing (a window's + // activation sweep); other hosts drop it, as they did before it existed. + placeUnshownLiveTerminals: z.boolean().optional() }) export const TerminalResolveActive = z.object({