mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Layout-owner build: main shows live terminals that lost their tab, at the same moments the window did (dark) (#31430)
* feat(layout): main places a swept workspace's live terminals; the window sweep only reads (switch-on builds only) The window's activation sweep (first visit, an empty or sleeping activation, a phone wake) now asks main, through its existing terminal.list call, to place that workspace's unshown live terminals before listing them. With the switch on the sweep writes nothing to the window store and counts a pane main names as shown, so it no longer seeds a fresh shell beside a terminal main just placed. Main's "still binding" check also covers a registration in flight. * fix(layout): a sweep's placement pass holds back only its own workspace's first surface (switch-on builds only) * test(layout): switch-on activation-gate and census-call tests read main's listing, not window binds
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<LiveTerminalPlacement> | null = null
|
||||
private placementAgain = false
|
||||
/** The one pass running now, without the passes queued behind it. */
|
||||
private placementPass: Promise<LiveTerminalPlacement> | null = null
|
||||
/** What the next pass covers: every workspace, or the ones a window's sweep asked for. */
|
||||
private placementQueued: 'all' | Set<string> | null = null
|
||||
/** The one pass running now, without the passes queued behind it, and what it covers. */
|
||||
private placementPass: {
|
||||
run: Promise<LiveTerminalPlacement>
|
||||
scope: 'all' | ReadonlySet<string>
|
||||
} | 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<LiveTerminalPlacement> {
|
||||
placeUnshownLiveTerminals(worktreeId?: string): Promise<LiveTerminalPlacement> {
|
||||
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<LiveTerminalPlacement> => {
|
||||
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<void> {
|
||||
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<void>((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<LiveTerminalPlacement> {
|
||||
await this.unbindPanesFromOtherWorkspacesTerminals()
|
||||
private async placeUnshownLiveTerminalsOnce(
|
||||
scope: 'all' | ReadonlySet<string>
|
||||
): Promise<LiveTerminalPlacement> {
|
||||
// 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
|
||||
|
||||
@@ -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<Map<string, ResolvedWorktree>> {
|
||||
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<LiveSession[]>((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
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user