refactor(pty): PTY controller inventory and startup waits take a host id (#26998)

The runtime PTY controller's listProcesses / supportsForegroundProcessEvidence
and the local daemon-startup waits took a connectionId where null meant
this machine and undefined meant every host. They now take an
ExecutionHostId (omitted = every host for inventory, this machine for
startup waits); id-routed callers pass the PTY's resolved host instead
of re-deriving a raw SSH connection id.

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 16:48:14 -07:00
committed by GitHub
co-authored by m4air
parent 1ad28ce81e
commit 8e4716aa65
32 changed files with 142 additions and 126 deletions
@@ -99,10 +99,7 @@ const mainWindow = {
}
function captureController(): {
listProcesses: (
connectionId?: string | null,
opts?: { deadlineMs?: number }
) => Promise<PtyProcessInfo[]>
listProcesses: (hostId?: string, opts?: { deadlineMs?: number }) => Promise<PtyProcessInfo[]>
} {
handleMock.mockReset()
onMock.mockReset()
@@ -177,7 +174,7 @@ describe('aggregate PTY process inventory', () => {
const controller = captureController()
const deadlineMs = Date.now() + 1200
await controller.listProcesses('conn-a', { deadlineMs })
await controller.listProcesses('ssh:conn-a', { deadlineMs })
expect(remote.calls).toEqual([{ opts: { deadlineMs } }])
})
@@ -3,6 +3,7 @@ import { spawnMock } from './pty-ipc-mock-registry'
import { makeDeferred } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import type { RuntimePtyController } from '../runtime/runtime-pty-controller-contract'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -105,31 +106,28 @@ describe('registerPtyHandlers', () => {
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
setPtyOwnership('ssh-b-pty', 'ssh:ssh-b')
const runtime = {
setPtyController: vi.fn(),
setPtyController: vi.fn((_controller: RuntimePtyController) => {}),
markPtyLivenessUnverifiable: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
listProcesses(connectionId?: string | null): Promise<{ id: string }[]>
listProcessesWithHostScope(): Promise<{ processes: { id: string }[]; hostIds: string[] }>
}
const controller = runtime.setPtyController.mock.calls[0]?.[0]
await expect(controller.listProcesses(null)).resolves.toEqual([
await expect(controller?.listProcesses?.('local')).resolves.toEqual([
{ id: 'local-pty', title: 'Local', cwd: '/local' }
])
expect(localList).toHaveBeenCalledOnce()
expect(sshAList).not.toHaveBeenCalled()
expect(sshBList).not.toHaveBeenCalled()
await expect(controller.listProcesses('ssh-a')).resolves.toEqual([{ id: 'ssh-a-pty' }])
await expect(controller?.listProcesses?.('ssh:ssh-a')).resolves.toEqual([{ id: 'ssh-a-pty' }])
expect(sshAList).toHaveBeenCalledOnce()
expect(sshBList).not.toHaveBeenCalled()
// STA-517: the aggregate used to propagate ssh-b's failure, which cost the runtime the
// whole liveness inventory — so no PTY was ever proven dead and mobile kept every
// retained pane "active". One unreachable relay now drops out of the answer instead.
await expect(controller.listProcesses()).resolves.toEqual([
await expect(controller?.listProcesses?.()).resolves.toEqual([
{ id: 'local-pty', title: 'Local', cwd: '/local' },
{ id: 'ssh-a-pty' }
])
@@ -139,7 +137,7 @@ describe('registerPtyHandlers', () => {
'ssh-b unavailable'
)
await expect(controller.listProcessesWithHostScope()).resolves.toEqual({
await expect(controller?.listProcessesWithHostScope?.()).resolves.toEqual({
processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }, { id: 'ssh-a-pty' }],
hostIds: [LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId('ssh-a')]
})
+1 -1
View File
@@ -63,7 +63,7 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
function installController(): {
controller: {
kill: (ptyId: string) => boolean
listProcesses: (connectionId?: string | null) => Promise<{ id: string }[]>
listProcesses: (hostId?: string) => Promise<{ id: string }[]>
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
stopAndWait: (ptyId: string, opts?: { deadlineMs?: number }) => Promise<boolean>
}
+4 -4
View File
@@ -11,12 +11,12 @@ import { ptyOwnership } from '../provider/ownership-state'
import {
getProviderForPty,
getProvider,
getPtySshConnectionId,
hasPtyProviderForInspection,
isDispatchablePtyHost,
registeredPtyProviders,
resolvePtyExecutionHost,
tryGetProviderForPty
tryGetProviderForPty,
type ResolvedPtyHost
} from '../provider/registry'
import {
getConnectionExecutionHostId,
@@ -32,7 +32,7 @@ import {
} from '../pane/serializer-state'
export function installPtyInspectIpcHandlers(deps: {
getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise<void> | undefined
}): void {
const ipcMain = getPtyIpc()
const { getLocalPtyProviderStartupPromise } = deps
@@ -42,7 +42,7 @@ export function installPtyInspectIpcHandlers(deps: {
// renderer-kill.ts inlines this — pty:kill's listener teardown is
// ordering-sensitive and must not gain even a no-barrier microtask.
const awaitSwapWindow = async (id: string): Promise<void> => {
await getLocalPtyProviderStartupPromise(getPtySshConnectionId(id))
await getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(id))
}
ipcMain.handle(
+5 -3
View File
@@ -9,7 +9,9 @@ import { ptyIncarnationById } from '../provider/ownership-state'
import {
getProviderForPty,
getPtySshConnectionId,
tryGetProviderForPty
resolvePtyExecutionHost,
tryGetProviderForPty,
type ResolvedPtyHost
} from '../provider/registry'
import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness'
import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill'
@@ -17,7 +19,7 @@ import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill'
export type PtyKillIpcDeps = {
store?: Store
runtime?: OrcaRuntimeService
getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise<void> | undefined
shutdownProviderAndDetectExit: (
provider: IPtyProvider,
id: string,
@@ -91,7 +93,7 @@ async function stopRendererOwnedPtyProcess(
runtime?.markPtyStopRequested?.(args.id)
const connectionId = getPtySshConnectionId(args.id)
// Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742).
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
const startupPromise = getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(args.id))
if (startupPromise) {
await startupPromise
}
+2 -1
View File
@@ -20,6 +20,7 @@ import type {
} from '../host-env/types'
import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume'
import type { StablePaneOwner } from '../pane/stable-owner'
import type { ResolvedPtyHost } from '../provider/registry'
export type PtySpawnIpcArgs = {
cols: number
@@ -91,7 +92,7 @@ export type PtySpawnIpcDeps = {
prepareCodexSessionResume?: PrepareCodexSessionResume
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
}
getLocalPtyStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
getLocalPtyStartupPromise: (hostId?: ResolvedPtyHost) => Promise<void> | undefined
adoptStablePane: (args: AdoptStablePaneArgs) => Promise<AdoptStablePaneResult | null>
assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise<void> | void
resolvePtySpawnStartupCwd: (
+4 -1
View File
@@ -1,13 +1,16 @@
import { getPtyIpc } from '../../pty-host-bindings'
import { runPtyIpcSpawn } from './spawn-run'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void {
const ipcMain = getPtyIpc()
const { getLocalPtyStartupPromise } = deps
ipcMain.handle('pty:spawn', async (_event, args: PtySpawnIpcArgs) => {
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
const startupPromise = getLocalPtyStartupPromise(
getConnectionExecutionHostId(args.connectionId)
)
if (startupPromise) {
await startupPromise
}
+7 -5
View File
@@ -66,7 +66,7 @@ export function registeredPtyProviders(): RegisteredPtyProvider[] {
}
export class PtyHostNotDispatchableError extends Error {
constructor(readonly hostId: ExecutionHostId | 'foreign') {
constructor(readonly hostId: ResolvedPtyHost) {
// Why: a paired runtime drives its own PTYs over runtime RPC; this process has no provider for them.
super(`PTY host "${hostId}" is not dispatchable from this process`)
this.name = 'PtyHostNotDispatchableError'
@@ -93,9 +93,11 @@ export function getProvider(hostId: ExecutionHostId): IPtyProvider {
return provider
}
/** The host that runs a PTY: its recorded owner, else the host its id names. 'foreign' = off this
* machine but unnameable. */
export function resolvePtyExecutionHost(ptyId: string): ExecutionHostId | 'foreign' {
/** 'foreign' = a PTY that runs off this machine on a host its id cannot name. */
export type ResolvedPtyHost = ExecutionHostId | 'foreign'
/** The host that runs a PTY: its recorded owner, else the host its id names. */
export function resolvePtyExecutionHost(ptyId: string): ResolvedPtyHost {
const owner = ptyOwnership.get(ptyId)
if (owner !== undefined) {
return owner
@@ -117,7 +119,7 @@ export function getPtySshConnectionId(ptyId: string): string | null {
}
/** Whether this process can hold a provider for the host at all (this machine or an SSH relay). */
export function isDispatchablePtyHost(hostId: ExecutionHostId | 'foreign'): boolean {
export function isDispatchablePtyHost(hostId: ResolvedPtyHost): boolean {
return (
hostId === LOCAL_EXECUTION_HOST_ID ||
(hostId !== 'foreign' && getSshTargetIdForExecutionHost(hostId) !== null)
+9 -5
View File
@@ -8,7 +8,7 @@ import {
getHiddenRendererPtyDeliveryDebug,
resetRendererScopedHiddenPtyDeliveryState
} from '../pty-hidden-delivery-gate'
import { localProvider } from './provider/registry'
import { localProvider, type ResolvedPtyHost } from './provider/registry'
import { finishPtyShutdown } from './provider/liveness'
import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types'
import { installPtyInspectIpcHandlers } from './ipc/inspect'
@@ -66,6 +66,7 @@ import {
stripSequencedStartupResumeArgv
} from './host-env/codex-resume'
import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
export function registerPtyHandlers(
mainWindow?: PtyRendererDelivery,
@@ -95,8 +96,11 @@ export function registerPtyHandlers(
setRebindProviderListeners(() => {})
registerRendererLifecycleResetHandlers(mainWindow?.webContents)
const getLocalPtyStartupPromise = (connectionId?: string | null): Promise<void> | undefined => {
if (connectionId) {
// Why only `local`: the daemon swap re-owns this machine's PTYs; no other host waits on it.
const getLocalPtyStartupPromise = (
hostId: ResolvedPtyHost = LOCAL_EXECUTION_HOST_ID
): Promise<void> | undefined => {
if (hostId !== LOCAL_EXECUTION_HOST_ID) {
return undefined
}
// Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon.
@@ -104,9 +108,9 @@ export function registerPtyHandlers(
}
const getLocalPtyProviderStartupPromise = (
connectionId?: string | null
hostId: ResolvedPtyHost = LOCAL_EXECUTION_HOST_ID
): Promise<void> | undefined => {
if (connectionId) {
if (hostId !== LOCAL_EXECUTION_HOST_ID) {
return undefined
}
return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.()
+3 -2
View File
@@ -18,13 +18,14 @@ import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/cod
import type { StablePaneOwner } from '../pane/stable-owner'
import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types'
import type { finishPtyShutdown } from '../provider/liveness'
import type { ResolvedPtyHost } from '../provider/registry'
export type PtyRuntimeControllerDeps = {
runtime?: OrcaRuntimeService
store?: Store
adoptStablePane: (args: AdoptStablePaneArgs) => Promise<AdoptStablePaneResult | null>
getLocalPtyStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise<void> | undefined
getLocalPtyStartupPromise: (hostId?: ResolvedPtyHost) => Promise<void> | undefined
getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise<void> | undefined
prepareCodexResumeHome: (args: {
connectionId?: string | null
launchAgent?: TuiAgent
+3 -4
View File
@@ -73,12 +73,11 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId),
resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId),
hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId),
listProcesses: (connectionId, opts) =>
listProcessesFromRuntimeController(deps, connectionId, opts),
listProcesses: (hostId, opts) => listProcessesFromRuntimeController(deps, hostId, opts),
listProcessesWithHostScope: (opts) =>
listProcessesWithHostScopeFromRuntimeController(deps, opts),
supportsForegroundProcessEvidence: (connectionId) =>
supportsForegroundProcessEvidenceFromRuntimeController(connectionId),
supportsForegroundProcessEvidence: (hostId) =>
supportsForegroundProcessEvidenceFromRuntimeController(hostId),
serializeBuffer: (ptyId, opts) => {
// Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly.
return requestSerializedBuffer(ptyId, opts)
@@ -1,21 +1,17 @@
import { getProvider, registeredPtyProviders } from '../provider/registry'
import {
getConnectionExecutionHostId,
LOCAL_EXECUTION_HOST_ID
} from '../../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
/** Probe the owning provider before opting into the no-process-table inventory projection. */
export async function supportsForegroundProcessEvidenceFromRuntimeController(
connectionId?: string | null
hostId?: ExecutionHostId
): Promise<boolean> {
if (connectionId === null) {
if (hostId === LOCAL_EXECUTION_HOST_ID) {
return true
}
if (connectionId === undefined) {
const providers = registeredPtyProviders()
if (hostId === undefined) {
const supported = await Promise.all(
providers.map(async ({ provider, hostId }) =>
hostId === LOCAL_EXECUTION_HOST_ID
registeredPtyProviders().map(async ({ provider, hostId: providerHostId }) =>
providerHostId === LOCAL_EXECUTION_HOST_ID
? true
: ((await provider.supportsForegroundProcessEvidence?.()) ?? false)
)
@@ -23,11 +19,7 @@ export async function supportsForegroundProcessEvidenceFromRuntimeController(
return supported.every(Boolean)
}
try {
return (
(await getProvider(
getConnectionExecutionHostId(connectionId)
).supportsForegroundProcessEvidence?.()) ?? false
)
return (await getProvider(hostId).supportsForegroundProcessEvidence?.()) ?? false
} catch {
return false
}
@@ -1,10 +1,6 @@
import { getPtyIdsForHost } from '../provider/ownership-state'
import { getProvider, localProvider, registeredPtyProviders } from '../provider/registry'
import {
LOCAL_EXECUTION_HOST_ID,
getConnectionExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import type { PtyProcessInfo } from '../../../providers/pty-process-info'
import type { PtyRuntimeControllerDeps } from './controller-deps'
@@ -49,14 +45,13 @@ export async function listProcessesWithHostScopeFromRuntimeController(
export async function listProcessesFromRuntimeController(
deps: PtyRuntimeControllerDeps,
connectionId?: string | null,
hostId?: ExecutionHostId,
opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean }
) {
if (connectionId === null) {
if (hostId === LOCAL_EXECUTION_HOST_ID) {
return localProvider.listProcesses()
}
if (connectionId !== undefined) {
const hostId = getConnectionExecutionHostId(connectionId)
if (hostId !== undefined) {
try {
return await getProvider(hostId).listProcesses(opts)
} catch (error) {
+7 -3
View File
@@ -1,7 +1,11 @@
import type { IPtyProvider } from '../../../providers/types'
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict'
import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state'
import { getProviderForPty, getPtySshConnectionId } from '../provider/registry'
import {
getProviderForPty,
getPtySshConnectionId,
resolvePtyExecutionHost
} from '../provider/registry'
import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness'
import { recordUndeliveredSshPtyKill } from './undelivered-ssh-kill'
import type { PtyRuntimeControllerDeps } from './controller-deps'
@@ -100,7 +104,7 @@ export function killPtyFromRuntimeController(
})
return true
}
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
const startupPromise = getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(ptyId))
if (startupPromise) {
// Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY.
void startupPromise.then(killWithCurrentProvider).catch((err) => {
@@ -167,7 +171,7 @@ export async function stopAndWaitPtyFromRuntimeController(
// below; each RPC leaf converts it to the remaining time when it issues, so
// sequential RPCs share the budget and cannot overrun the sweep deadline.
const deadlineMs = opts?.deadlineMs
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
const startupPromise = getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(ptyId))
if (startupPromise) {
// Why: exact-stop must resolve the provider after daemon startup just
// like renderer kills, or the fallback can falsely confirm teardown.
+3 -10
View File
@@ -5,12 +5,7 @@ import { LocalPtyProvider } from '../../../providers/local-pty-provider'
import { ptyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { rendererSerializerReadiness } from '../pane/serializer-state'
import {
getProviderForPty,
getPtySshConnectionId,
localProvider,
resolvePtyExecutionHost
} from '../provider/registry'
import { getProviderForPty, localProvider, resolvePtyExecutionHost } from '../provider/registry'
import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection'
import type { PtyRuntimeControllerDeps } from './controller-deps'
import {
@@ -97,10 +92,9 @@ export async function probePtyLivenessFromRuntimeController(
if (isRemoteRuntimePtyId(ptyId)) {
return null
}
const connectionId = getPtySshConnectionId(ptyId)
// Why: during cold start the daemon swap is in flight; the pre-swap
// fallback would answer absent for every daemon-owned id.
const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId)
const startupPromise = deps.getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(ptyId))
if (startupPromise) {
await startupPromise
}
@@ -244,8 +238,7 @@ export function hasPtyFromRuntimeController(
if (isRemoteRuntimePtyId(ptyId)) {
return null
}
const connectionId = getPtySshConnectionId(ptyId)
const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId)
const startupPromise = deps.getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(ptyId))
if (startupPromise && !settledLocalPtyProviderStartups.has(startupPromise)) {
// Why: a sync probe cannot wait out the cold-start daemon swap the way
// probePtyLiveness does, and the pre-swap provider's "no PTY" for a
+4 -1
View File
@@ -5,6 +5,7 @@ import { snapshotCodexPaneHomeRoutes, codexReattachedHomeRouteField } from '../h
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry'
import type { RuntimePtySpawnState } from './spawn-state'
import { getConnectionExecutionHostId } from '../../../../shared/execution-host'
export function adoptMaterializedRuntimePtySpawn(
ctx: RuntimePtySpawnState,
@@ -28,7 +29,9 @@ export function adoptMaterializedRuntimePtySpawn(
)
: new Map<string, CodexPaneHomeRoute | null>()
}
const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId)
const startupPromise = ctx.deps.getLocalPtyStartupPromise(
getConnectionExecutionHostId(args.connectionId)
)
if (startupPromise && !startupAlreadyAwaited) {
return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true))
}
@@ -8,6 +8,7 @@ import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { judgeTerminalForeground, readTerminalProcessRows } from './terminal-foreground-group'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
/**
* What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell
@@ -61,7 +62,7 @@ export async function readLaunchedAgentForeground(
return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown'
}
if (!host.remote) {
const rootPid = (await controller.listProcesses?.(null))?.find(
const rootPid = (await controller.listProcesses?.(LOCAL_EXECUTION_HOST_ID))?.find(
(pane) => pane.id === ptyId
)?.rootProcessId
const rows = rootPid ? await readTerminalProcessRows(rootPid) : null
@@ -441,7 +441,7 @@ describe('agent-session create operation ledger', () => {
disposition: 'replayed',
terminal: { handle: orphanHandle, ptyId: 'ssh-1:pty2:e:1', worktreeId: 'worktree-1' }
})
expect(listProcesses).toHaveBeenCalledWith('ssh-1')
expect(listProcesses).toHaveBeenCalledWith('ssh:ssh-1')
expect(createTerminal).toHaveBeenCalledOnce()
expect(failure.message).toBe('execution_owner_unavailable')
})
@@ -455,7 +455,7 @@ describe('agent-session create operation ledger', () => {
await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow(
failure.message
)
expect(listProcesses).toHaveBeenCalledWith('ssh-1')
expect(listProcesses).toHaveBeenCalledWith('ssh:ssh-1')
expect(createTerminal).toHaveBeenCalledOnce()
})
@@ -52,7 +52,9 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu
if (
targetConnectionId !== null &&
this.ptyController.supportsForegroundProcessEvidence &&
!(await this.ptyController.supportsForegroundProcessEvidence(targetConnectionId))
!(await this.ptyController.supportsForegroundProcessEvidence(
parsedTargetHost?.kind === 'ssh' ? parsedTargetHost.id : undefined
))
) {
// A legacy relay ignores the optional projection and would still run its
// expensive process-table inventory on every mobile cadence tick.
@@ -8,7 +8,7 @@ const SSH_SCOPE = JSON.stringify({ kind: 'ssh', targetId: 'ssh-1' })
const PROCESS_INCARNATION = 'remote:ssh-1:pty-1:inc-1'
function runtimeWithInventory(
listProcesses: (connectionId?: string | null) => Promise<PtyProcessInfo[]>
listProcesses: (hostId?: string) => Promise<PtyProcessInfo[]>
): OrcaRuntimeService {
const runtime = new OrcaRuntimeService()
runtime.setPtyController({
@@ -38,8 +38,8 @@ describe('terminal process incarnation liveness', () => {
await expect(
runtime.inspectTerminalProcessIncarnationLiveness('remote:ssh-1:pty-1:inc-old', SSH_SCOPE)
).resolves.toBe('exited')
expect(listProcesses).toHaveBeenNthCalledWith(1, 'ssh-1')
expect(listProcesses).toHaveBeenNthCalledWith(2, 'ssh-1')
expect(listProcesses).toHaveBeenNthCalledWith(1, 'ssh:ssh-1')
expect(listProcesses).toHaveBeenNthCalledWith(2, 'ssh:ssh-1')
})
it('keeps missing or malformed identity and unavailable inventory unverifiable', async () => {
@@ -77,16 +77,16 @@ describe('terminal process incarnation liveness', () => {
})
it.each([
[{ kind: 'local', hostId: 'local' }, null],
[{ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }, null]
] as const)('uses the local provider inventory for %s scope', async (scope, connectionId) => {
[{ kind: 'local', hostId: 'local' }, 'local'],
[{ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }, 'local']
] as const)('uses the local provider inventory for %s scope', async (scope, hostId) => {
const listProcesses = vi.fn().mockResolvedValue([])
const runtime = runtimeWithInventory(listProcesses)
await expect(
runtime.inspectTerminalProcessIncarnationLiveness('local-pty:inc-1', JSON.stringify(scope))
).resolves.toBe('exited')
expect(listProcesses).toHaveBeenCalledWith(connectionId)
expect(listProcesses).toHaveBeenCalledWith(hostId)
})
})
@@ -6,8 +6,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import {
getConnectionExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toSshExecutionHostId
parseExecutionHostId
} from '../../shared/execution-host'
import {
PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS,
@@ -77,13 +76,9 @@ export class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory ext
const processInventory =
connectionId === undefined && this.ptyController.listProcessesWithHostScope
? this.ptyController.listProcessesWithHostScope(providerListOpts)
: this.ptyController.listProcesses(connectionId, providerListOpts).then((processes) => {
const hostId: ExecutionHostId =
connectionId === undefined || connectionId === null
? LOCAL_EXECUTION_HOST_ID
: toSshExecutionHostId(connectionId)
return { processes, hostIds: [hostId] }
})
: this.ptyController
.listProcesses(connectionId === undefined ? undefined : providerKey, providerListOpts)
.then((processes) => ({ processes, hostIds: [providerKey] }))
const sessionsResult = await withTimeoutResult(processInventory, listBudgetMs)
if (!sessionsResult.ok) {
return null
@@ -24,6 +24,7 @@ import {
} from './orchestration/worker-terminal-process-liveness'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApplyMobileDisplayMode {
subscribeToTerminalResize(
@@ -176,7 +177,11 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
return 'unverifiable'
}
const listed = await withTimeoutResult(
this.ptyController.listProcesses(hostScope.kind === 'ssh' ? hostScope.targetId : null),
this.ptyController.listProcesses(
hostScope.kind === 'ssh'
? toSshExecutionHostId(hostScope.targetId)
: LOCAL_EXECUTION_HOST_ID
),
PTY_CONTROLLER_LIST_TIMEOUT_MS
)
if (!listed.ok) {
@@ -7,7 +7,11 @@ import { withTimeoutResult } from './runtime-async-boundaries'
import { PTY_CONTROLLER_LIST_TIMEOUT_MS } from './orca-runtime-postlude'
import { inferWorktreeIdFromPtyId } from './runtime-worktree-path-identity'
import { getRegisteredSshState } from '../ssh/ssh-target-registry'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import {
getConnectionExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
toSshExecutionHostId
} from '../../shared/execution-host'
import { resolveWorktreeLaunchHost } from './worktree-launch-host-repo'
import type { TuiAgent } from '../../shared/tui-agent'
@@ -69,7 +73,9 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC
throw new Error('runtime_unavailable')
}
const listed = await withTimeoutResult(
this.ptyController.listProcesses(connectionId),
this.ptyController.listProcesses(
connectionId === undefined ? undefined : getConnectionExecutionHostId(connectionId)
),
PTY_CONTROLLER_LIST_TIMEOUT_MS
)
if (!listed.ok) {
@@ -259,7 +259,7 @@ describe('terminal create idempotency', () => {
})
// Mirrors listProcessesFromRuntimeController: `undefined` aggregates every provider and
// silently drops a non-answering SSH host, `null` is local-only, a string is host-scoped
// silently drops a non-answering SSH host, `local` is local-only, an `ssh:` host is host-scoped
// and rethrows the host's failure.
function createHostScopedInventory(hosts: {
local?: PtyProcessInfo[]
@@ -267,12 +267,12 @@ function createHostScopedInventory(hosts: {
}) {
const local = hosts.local ?? []
const ssh = hosts.ssh ?? {}
return vi.fn(async (connectionId?: string | null): Promise<PtyProcessInfo[]> => {
if (connectionId === null) {
return vi.fn(async (hostId?: string): Promise<PtyProcessInfo[]> => {
if (hostId === 'local') {
return local
}
if (typeof connectionId === 'string') {
const host = ssh[connectionId]
if (typeof hostId === 'string') {
const host = ssh[hostId.slice('ssh:'.length)]
if (host === undefined || host === 'unreachable') {
throw new Error('ssh relay did not answer')
}
@@ -310,7 +310,7 @@ describe('terminal create reconciliation scopes inventory to the owning executio
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
).rejects.toThrow('runtime_unavailable')
expect(create).not.toHaveBeenCalled()
expect(listProcesses).toHaveBeenCalledWith('ssh-1')
expect(listProcesses).toHaveBeenCalledWith('ssh:ssh-1')
})
it('adopts the original PTY from the owning host listing', async () => {
@@ -343,7 +343,7 @@ describe('terminal create reconciliation scopes inventory to the owning executio
)
expect(create).toHaveBeenCalledWith('id:worktree-1', result.handle)
expect(listProcesses).toHaveBeenCalledWith('ssh-1')
expect(listProcesses).toHaveBeenCalledWith('ssh:ssh-1')
})
it('scopes the listing to the local host for a workspace with no connection', async () => {
@@ -357,7 +357,7 @@ describe('terminal create reconciliation scopes inventory to the owning executio
await expect(
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@session-a' })
expect(listProcesses).toHaveBeenCalledWith(null)
expect(listProcesses).toHaveBeenCalledWith('local')
expect(create).not.toHaveBeenCalled()
})
@@ -377,6 +377,6 @@ describe('terminal create reconciliation scopes inventory to the owning executio
)
expect(create).toHaveBeenCalledWith('id:folder:folder-1', result.handle)
expect(listProcesses).toHaveBeenCalledWith(null)
expect(listProcesses).toHaveBeenCalledWith('local')
})
})
@@ -198,7 +198,7 @@ describe('OrcaRuntimeService', () => {
expect(listWorktrees).not.toHaveBeenCalled()
expect(listProcesses).toHaveBeenCalledOnce()
expect(listProcesses).toHaveBeenCalledWith(
'ssh-target',
'ssh:ssh-target',
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(result).toMatchObject({
@@ -212,7 +212,7 @@ describe('OrcaRuntimeService', () => {
paneKey: makePaneKey('tab-agent', HEADLESS_LEAF_ID)
})
expect(listProcesses).toHaveBeenCalledTimes(inventoryCount + 1)
expect(listProcesses).toHaveBeenLastCalledWith(null, LIST_PROVIDER_DEADLINE)
expect(listProcesses).toHaveBeenLastCalledWith('local', LIST_PROVIDER_DEADLINE)
expect(
(await runtime.listTerminals()).terminals.find((terminal) => terminal.ptyId === 'pty-agent')
).toMatchObject({
@@ -307,8 +307,8 @@ describe('OrcaRuntimeService', () => {
}
]
} as unknown as OrchestrationDb)
const listProcesses = vi.fn(async (connectionId?: string | null) => {
if (connectionId !== null) {
const listProcesses = vi.fn(async (hostId?: string) => {
if (hostId !== 'local') {
throw new Error('unrelated SSH inventory must not run')
}
return [
@@ -347,7 +347,7 @@ describe('OrcaRuntimeService', () => {
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
expect(getSession().sleepingAgentSessionsByPaneKey?.[secondWorkerPaneKey]).toBeUndefined()
expect(listProcesses).toHaveBeenCalledTimes(3)
expect(listProcesses).toHaveBeenCalledWith(null, LIST_PROVIDER_DEADLINE)
expect(listProcesses).toHaveBeenCalledWith('local', LIST_PROVIDER_DEADLINE)
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([
expect.objectContaining({ id: 'legacy-worker-two', ptyId: 'pty-exited-two' })
])
@@ -460,7 +460,12 @@ describe('OrcaRuntimeService', () => {
await vi.advanceTimersByTimeAsync(1_000)
expect(listProcesses).toHaveBeenCalledTimes(4)
expect(listProcesses.mock.calls.map((call) => call[0])).toEqual([null, null, null, null])
expect(listProcesses.mock.calls.map((call) => call[0])).toEqual([
'local',
'local',
'local',
'local'
])
expect(hasPty).not.toHaveBeenCalled()
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([
expect.objectContaining({ id: 'legacy-worker', ptyId: 'pty-inventory-unavailable' })
@@ -151,7 +151,7 @@ describe('OrcaRuntimeService', () => {
deferredDispatchIds: ['dispatch-missing', 'dispatch-ambiguous']
})
expect(listProcesses).toHaveBeenCalledOnce()
expect(listProcesses).toHaveBeenCalledWith(null, LIST_PROVIDER_DEADLINE)
expect(listProcesses).toHaveBeenCalledWith('local', LIST_PROVIDER_DEADLINE)
expect(getForegroundProcess).not.toHaveBeenCalled()
await runtime.refreshPtyForegroundAgentFromController('pty-ambiguous')
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-ambiguous')
@@ -263,7 +263,7 @@ describe('OrcaRuntimeService', () => {
)
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
expect(listProcesses).toHaveBeenCalledTimes(3)
expect(listProcesses).toHaveBeenCalledWith(null, LIST_PROVIDER_DEADLINE)
expect(listProcesses).toHaveBeenCalledWith('local', LIST_PROVIDER_DEADLINE)
expect(revealTerminalSession).toHaveBeenCalledWith(TEST_FOLDER_WORKSPACE_KEY, {
ptyId: 'pty-folder-legacy',
title: 'Folder worker',
@@ -400,7 +400,7 @@ describe('OrcaRuntimeService', () => {
expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`)
expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`)
expect(listProcesses).toHaveBeenCalledTimes(3)
expect(listProcesses).toHaveBeenCalledWith(connectionId, LIST_PROVIDER_DEADLINE)
expect(listProcesses).toHaveBeenCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE)
expect(sshSession.tabsByWorktree[TEST_FOLDER_WORKSPACE_KEY]).toContainEqual(
expect.objectContaining({
id: 'legacy-ssh-folder-worker',
@@ -167,7 +167,7 @@ describe('OrcaRuntimeService', () => {
exitedDispatchIds: [],
deferredDispatchIds: []
})
expect(listProcesses).toHaveBeenLastCalledWith(connectionId, LIST_PROVIDER_DEADLINE)
expect(listProcesses).toHaveBeenLastCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE)
} finally {
unregisterSshGitProvider(connectionId)
}
@@ -310,7 +310,13 @@ describe('OrcaRuntimeService', () => {
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
expect(revealTerminalSession).toHaveBeenCalledOnce()
expect(listProcesses).toHaveBeenCalledTimes(5)
expect(listProcesses.mock.calls.map((call) => call[0])).toEqual([null, null, null, null, null])
expect(listProcesses.mock.calls.map((call) => call[0])).toEqual([
'local',
'local',
'local',
'local',
'local'
])
})
it('restores orphan pane and group topology without replacing a newer host-owned tab', async () => {
@@ -25,7 +25,7 @@ describe('lifecycle invalidation respects inventory scope and retry bounds', ()
reply.resolve([processRow('ssh:host-b@@child')])
expect(await pending).toBeNull()
expect(list).toHaveBeenCalledTimes(1)
expect(list.mock.calls[0][0]).toBe('host-b')
expect(list.mock.calls[0][0]).toBe('ssh:host-b')
expect(hasPty).not.toHaveBeenCalled()
expect(runtime.capture(id).connected).toBe(true)
}
@@ -88,7 +88,7 @@ describe('lifecycle invalidation respects inventory scope and retry bounds', ()
const a = deferred<PtyProcessInfo[]>()
const b = deferred<PtyProcessInfo[]>()
const { runtime } = createInventoryRuntime((connection) =>
connection === 'a' ? a.promise : b.promise
connection === 'ssh:a' ? a.promise : b.promise
)
const pa = runtime.read('a')
const pb = runtime.read('b')
+2 -1
View File
@@ -8,6 +8,7 @@ import {
confirmRootShellAloneFromProcessTable,
inspectionShowsShellAlone
} from './run-terminal-shell-alone'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
/**
* Whether a client drove or is viewing a PTY's current process, for closing finished run
@@ -47,7 +48,7 @@ export async function confirmRunTerminalShellAlone(
(await controller?.inspectProcess?.(ptyId, { scanChildProcesses: true })) ?? null
)
}
const processes = (await controller?.listProcesses?.(null)) ?? []
const processes = (await controller?.listProcesses?.(LOCAL_EXECUTION_HOST_ID)) ?? []
const rootPid = processes.find((entry) => entry.id === ptyId)?.rootProcessId
return rootPid ? await confirmRootShellAloneFromProcessTable(rootPid) : false
} catch {
@@ -135,8 +135,9 @@ export type RuntimePtyController = {
resize?(ptyId: string, cols: number, rows: number): boolean
// Why: exact-id mobile polls should not enumerate every local and SSH PTY.
hasPty?(ptyId: string): boolean | null
/** Omitting the host lists every registered host. */
listProcesses?(
connectionId?: string | null,
hostId?: ExecutionHostId,
opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean }
): Promise<PtyProcessInfo[]>
listProcessesWithHostScope?(opts?: {
@@ -146,7 +147,7 @@ export type RuntimePtyController = {
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}>
supportsForegroundProcessEvidence?(connectionId?: string | null): Promise<boolean>
supportsForegroundProcessEvidence?(hostId?: ExecutionHostId): Promise<boolean>
serializeBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number }