fix(terminal): preserve unverifiable host scope

This commit is contained in:
Brennan Benson
2026-08-16 18:38:20 -07:00
parent c934754262
commit 147a140f0e
6 changed files with 297 additions and 37 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
export function formatTerminalList(result: RuntimeTerminalListResult): string {
const scope = formatTerminalListHostScope(result.hostScope)
if (result.terminals.length === 0) {
return `No live terminals.\n${scope}`
return `No terminals listed.\n${scope}`
}
const body = result.terminals
.map(
@@ -62,7 +62,7 @@ describe('formatTerminalList scope declaration', () => {
})
)
expect(output).toContain('No live terminals')
expect(output).toContain('No terminals listed')
expect(output).toContain('scope: local')
expect(output).toContain('not covered: ssh:box-1')
})
+24 -6
View File
@@ -17,7 +17,11 @@ import type { PtyBindingSourceExpectation, Store } from '../persistence'
import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { TuiAgent } from '../../shared/tui-agent'
import { toSshExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy'
import type {
@@ -72,6 +76,7 @@ import {
} from '../../shared/pi-agent-kind'
import { isPwshAvailableAsync } from '../pwsh'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import type { PtyProcessInfo } from '../providers/pty-process-info'
import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
@@ -259,6 +264,22 @@ function registeredPtyProviders(): RegisteredPtyProvider[] {
]
}
async function listRegisteredPtyProcessesWithHostScope(): Promise<{
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}> {
const providers = registeredPtyProviders()
const providerSessions = await Promise.all(
providers.map(({ provider }) => provider.listProcesses())
)
return {
processes: providerSessions.flat(),
hostIds: providers.map(({ connectionId }) =>
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
)
}
}
const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring.
const PRODUCER_FLOW_CONTROL_ENABLED = true
@@ -5747,12 +5768,9 @@ export function registerPtyHandlers(
if (connectionId !== undefined) {
return getProvider(connectionId).listProcesses()
}
const providerSessions = await Promise.all([
localProvider.listProcesses(),
...Array.from(sshProviders.values(), (provider) => provider.listProcesses())
])
return providerSessions.flat()
return (await listRegisteredPtyProcessesWithHostScope()).processes
},
listProcessesWithHostScope: listRegisteredPtyProcessesWithHostScope,
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)
+91 -23
View File
@@ -1869,6 +1869,10 @@ type RuntimePtyController = {
// Why: exact-id mobile polls should not enumerate every local and SSH PTY.
hasPty?(ptyId: string): boolean | null
listProcesses?(connectionId?: string | null): Promise<PtyProcessInfo[]>
listProcessesWithHostScope?(): Promise<{
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}>
serializeBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
@@ -1913,6 +1917,7 @@ type PtyControllerInventory = Readonly<{
// must consult the unscoped inventory or a misattributed live PTY reads as dead.
allLivePtyIds: ReadonlySet<string>
terminalIdentityByPtyId: ReadonlyMap<string, PtyControllerTerminalIdentity>
queriedHostIds: ReadonlySet<ExecutionHostId>
}>
type WorktreeStartupDraftPaste = {
@@ -5818,16 +5823,38 @@ export class OrcaRuntimeService {
return worktreeIds
}
// Every execution host this runtime can answer for; 'local' is always one of
// them because the runtime itself runs somewhere.
private listKnownExecutionHostIds(): Set<ExecutionHostId> {
// Every execution host known to this runtime; knowledge is not coverage.
private listKnownExecutionHostIds(
additionalHostIds: Iterable<ExecutionHostId> = [],
includeConfiguredHosts = true
): Set<ExecutionHostId> {
const hostIds = new Set<ExecutionHostId>([LOCAL_EXECUTION_HOST_ID])
for (const repo of this.store?.getRepos?.() ?? []) {
hostIds.add(getRepoExecutionHostId(repo))
}
for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) {
hostIds.add(hostId)
}
for (const hostId of additionalHostIds) {
hostIds.add(hostId)
}
if (!includeConfiguredHosts) {
return hostIds
}
const repos = this.store?.getRepos?.() ?? []
for (const repo of repos) {
hostIds.add(getRepoExecutionHostId(repo))
}
const projectGroups = this.store?.getProjectGroups?.() ?? []
for (const workspace of this.store?.getFolderWorkspaces?.() ?? []) {
const connection = inferFolderWorkspacePathConnection({
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
connectionId: workspace.connectionId ?? null,
projectGroups,
repos
})
if (connection.kind === 'ssh') {
hostIds.add(toSshExecutionHostId(connection.connectionId))
}
}
return hostIds
}
@@ -16653,7 +16680,12 @@ export class OrcaRuntimeService {
return {
terminals: listedTerminals,
hostScope: this.buildTerminalListHostScope(targetWorktreeId, matchingTerminals),
hostScope: this.buildTerminalListHostScope(
targetWorktreeId,
matchingTerminals,
worktreesById.values(),
controllerInventory?.queriedHostIds ?? new Set()
),
...(visualLayouts.length > 0 ? { visualLayouts } : {}),
topologyRevisions: Object.fromEntries(
[...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [
@@ -16667,25 +16699,38 @@ export class OrcaRuntimeService {
}
// A worktree-scoped request answers for one host only, so name the hosts it
// skipped: absence from a scoped listing is not evidence a worker died.
// skipped: absence from a scoped listing is not evidence a worker exited.
private buildTerminalListHostScope(
targetWorktreeId: string | null,
terminals: readonly RuntimeTerminalSummary[]
terminals: readonly RuntimeTerminalSummary[],
worktrees: Iterable<ResolvedWorktree>,
queriedHostIds: ReadonlySet<ExecutionHostId>
): RuntimeTerminalListHostScope {
const knownHostIds = this.listKnownExecutionHostIds()
const knownHostIds = this.listKnownExecutionHostIds(
queriedHostIds,
targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID
)
for (const worktree of worktrees) {
if (worktree.hostId) {
knownHostIds.add(worktree.hostId)
}
}
for (const terminal of terminals) {
if (terminal.executionHostId) {
knownHostIds.add(terminal.executionHostId)
}
}
const scopedHostId = targetWorktreeId
? this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)
: null
const coveredHostIds = new Set<ExecutionHostId>(
targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds
const candidates = targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds
// Paired runtimes own a separate control plane. Mirrored rows are evidence
// for those rows only; this runtime cannot claim their complete inventory.
const coveredHostIds = new Set(
[...candidates].filter(
(hostId) => queriedHostIds.has(hostId) && parseExecutionHostId(hostId)?.kind !== 'runtime'
)
)
// A listed row's host is covered by definition, even when it is a paired
// runtime this host would not otherwise enumerate.
for (const terminal of terminals) {
if (terminal.executionHostId) {
coveredHostIds.add(terminal.executionHostId)
}
}
return {
hostIds: [...coveredHostIds].sort(),
omittedHostIds: [...knownHostIds].filter((hostId) => !coveredHostIds.has(hostId)).sort()
@@ -31249,7 +31294,8 @@ export class OrcaRuntimeService {
return {
livePtyIds: targetedLiveness,
allLivePtyIds: targetedLiveness,
terminalIdentityByPtyId: new Map()
terminalIdentityByPtyId: new Map(),
queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID])
}
}
}
@@ -31264,8 +31310,28 @@ export class OrcaRuntimeService {
} else {
this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration)
}
const processInventory =
connectionId === undefined && this.ptyController.listProcessesWithHostScope
? this.ptyController.listProcessesWithHostScope()
: this.ptyController.listProcesses(connectionId).then((processes) => {
const hostIds = new Set<ExecutionHostId>()
if (connectionId === undefined || connectionId === null) {
hostIds.add(LOCAL_EXECUTION_HOST_ID)
} else {
hostIds.add(toSshExecutionHostId(connectionId))
}
if (connectionId === undefined) {
for (const process of processes) {
const ssh = parseAppSshPtyId(process.id)
if (ssh) {
hostIds.add(toSshExecutionHostId(ssh.connectionId))
}
}
}
return { processes, hostIds: [...hostIds] }
})
const sessionsResult = await withTimeoutResult(
this.ptyController.listProcesses(connectionId),
processInventory,
deadline === undefined
? PTY_CONTROLLER_LIST_TIMEOUT_MS
: Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now()))
@@ -31286,7 +31352,8 @@ export class OrcaRuntimeService {
if (!isCurrentInventory) {
return null
}
const sessions = sessionsResult.value
const sessions = sessionsResult.value.processes
const queriedHostIds = new Set(sessionsResult.value.hostIds)
const controllerIdentityByPtyId = new Map<string, PtyControllerTerminalIdentity>()
const ptyIdByControllerHandle = new Map<string, string>()
const ambiguousControllerPtyIds = new Set<string>()
@@ -31465,7 +31532,8 @@ export class OrcaRuntimeService {
return {
livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds,
allLivePtyIds,
terminalIdentityByPtyId: controllerIdentityByPtyId
terminalIdentityByPtyId: controllerIdentityByPtyId,
queriedHostIds
}
}
@@ -2,9 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { folderWorkspaceKey } from '../../shared/workspace-scope'
// An agent once declared a task dead because `terminal list` came back without
// its worker: the worker was alive on an SSH host, and nothing in the response
// An agent once declared a task exited because `terminal list` came back without
// its worker: the worker was live on an SSH host, and nothing in the response
// said the listing was scoped or which host each row ran on.
const LOCAL_WORKTREE_ID = 'repo-local::/tmp/local-worktree'
@@ -35,6 +36,9 @@ function makeStore() {
const session: WorkspaceSessionState = getDefaultWorkspaceSession()
return {
getWorkspaceSession: vi.fn(() => session),
getWorkspaceSessionHostIds: vi.fn(() => ['local', 'ssh:box-1']),
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
setWorkspaceSession: vi.fn(),
getRepos: vi.fn(() => REPOS),
getRepo: vi.fn((id: string) => REPOS.find((repo) => repo.id === id)),
@@ -47,10 +51,29 @@ function makeStore() {
}
}
function makeSshFolderWorkspace() {
return {
id: 'folder-ssh',
projectGroupId: 'group-1',
name: 'SSH folder',
folderPath: '/remote/folder',
connectionId: 'box-2',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 0,
updatedAt: 0
}
}
type GraphLeaf = { worktreeId: string; leafId: string; ptyId: string }
function makeRuntime(leaves: GraphLeaf[]): OrcaRuntimeService {
const runtime = new OrcaRuntimeService(makeStore() as never)
function makeRuntime(leaves: GraphLeaf[], store = makeStore()): OrcaRuntimeService {
const runtime = new OrcaRuntimeService(store as never)
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'never' })),
write: () => true,
@@ -132,6 +155,157 @@ describe('listTerminals scope declaration', () => {
expect(result.hostScope?.omittedHostIds).toEqual([])
})
it('does not claim a paired runtime was covered from a mirrored row', async () => {
const runtime = makeRuntime([
{
worktreeId: LOCAL_WORKTREE_ID,
leafId: REMOTE_LEAF_ID,
ptyId: 'remote:env-7@@handle-1'
}
])
const result = await runtime.listTerminals()
expect(result.hostScope?.hostIds).toEqual(['local'])
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-7', 'ssh:box-1'])
})
it('keeps a repo-known paired runtime omitted without a mirrored row', async () => {
const baseStore = makeStore()
const repos = [
REPOS[0]!,
{
id: 'repo-runtime',
path: '/remote/runtime-worktree',
displayName: 'runtime',
badgeColor: '#000000',
addedAt: 0,
executionHostId: 'runtime:env-9' as const
}
]
const runtime = makeRuntime([], {
...baseStore,
getRepos: vi.fn(() => repos),
getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)),
getWorkspaceSessionHostIds: vi.fn(() => ['local'])
})
const result = await runtime.listTerminals()
expect(result.hostScope?.hostIds).toEqual(['local'])
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9'])
})
it('keeps a repo-known paired runtime omitted in a worktree-scoped listing', async () => {
const baseStore = makeStore()
const repos = [
REPOS[0]!,
{
id: 'repo-runtime',
path: '/remote/runtime-worktree',
displayName: 'runtime',
badgeColor: '#000000',
addedAt: 0,
executionHostId: 'runtime:env-9' as const
}
]
const runtime = makeRuntime([], {
...baseStore,
getRepos: vi.fn(() => repos),
getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)),
getWorkspaceSessionHostIds: vi.fn(() => ['local'])
})
const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`)
expect(result.hostScope?.hostIds).toEqual(['local'])
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9'])
})
it('covers a queried SSH host used only by a folder workspace', async () => {
const baseStore = makeStore()
const folderWorkspace = makeSshFolderWorkspace()
const runtime = makeRuntime([], {
...baseStore,
getRepos: vi.fn(() => [REPOS[0]!]),
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
})
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'never' })),
write: () => true,
kill: () => true,
listProcesses: vi.fn(async () => []),
listProcessesWithHostScope: vi.fn(async () => ({
processes: [],
hostIds: ['local', 'ssh:box-2']
}))
} as never)
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
expect(result.hostScope?.hostIds).toEqual(['ssh:box-2'])
expect(result.hostScope?.omittedHostIds).toEqual(['local'])
})
it('keeps a disconnected folder-workspace SSH host omitted', async () => {
const baseStore = makeStore()
const folderWorkspace = makeSshFolderWorkspace()
const runtime = makeRuntime([], {
...baseStore,
getRepos: vi.fn(() => [REPOS[0]!]),
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
})
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'never' })),
write: () => true,
kill: () => true,
listProcesses: vi.fn(async () => {
throw new Error('relay unavailable')
})
} as never)
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
expect(result.hostScope?.hostIds).toEqual([])
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-2'])
})
it('keeps a disconnected SSH host omitted when only local inventory answered', async () => {
const runtime = makeRuntime([])
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'never' })),
write: () => true,
kill: () => true,
listProcesses: vi.fn(async () => [])
} as never)
const result = await runtime.listTerminals()
expect(result.hostScope?.hostIds).toEqual(['local'])
expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1'])
})
it('marks every known host omitted when process inventory is unverifiable', async () => {
const runtime = makeRuntime([])
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: 'never' })),
write: () => true,
kill: () => true,
listProcesses: vi.fn(async () => {
throw new Error('relay unavailable')
})
} as never)
const result = await runtime.listTerminals()
expect(result.hostScope?.hostIds).toEqual([])
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-1'])
})
it('reports the hosts a worktree-scoped listing skipped, so an empty result is not absolute', async () => {
const runtime = makeRuntime([
{ worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' }
+2 -2
View File
@@ -521,8 +521,8 @@ export type RuntimeTerminalVisualLayout = {
* answering runtime names them — `_meta.runtimeId` says which runtime that is. */
export type RuntimeTerminalListHostScope = {
hostIds: ExecutionHostId[]
/** Known hosts this listing skipped; a live terminal on one of them is absent
* from `terminals` without being dead. */
/** Known hosts this listing skipped; a live terminal on one of them can be
* absent from `terminals` without having exited. */
omittedHostIds: ExecutionHostId[]
}