diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 8d78096270b..4f136e96c65 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -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( diff --git a/src/cli/terminal-list-host-scope-format.test.ts b/src/cli/terminal-list-host-scope-format.test.ts index c1b8b4fad4c..30a7564a69b 100644 --- a/src/cli/terminal-list-host-scope-format.test.ts +++ b/src/cli/terminal-list-host-scope-format.test.ts @@ -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') }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2a6b3ebed84..ba1255c058e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0d93b778f3b..3bc9cf54deb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 + 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 terminalIdentityByPtyId: ReadonlyMap + queriedHostIds: ReadonlySet }> 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 { + // Every execution host known to this runtime; knowledge is not coverage. + private listKnownExecutionHostIds( + additionalHostIds: Iterable = [], + includeConfiguredHosts = true + ): Set { const hostIds = new Set([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, + queriedHostIds: ReadonlySet ): 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( - 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() + 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() const ptyIdByControllerHandle = new Map() const ambiguousControllerPtyIds = new Set() @@ -31465,7 +31532,8 @@ export class OrcaRuntimeService { return { livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, allLivePtyIds, - terminalIdentityByPtyId: controllerIdentityByPtyId + terminalIdentityByPtyId: controllerIdentityByPtyId, + queriedHostIds } } diff --git a/src/main/runtime/terminal-list-execution-host-scope.test.ts b/src/main/runtime/terminal-list-execution-host-scope.test.ts index 1e5fab7cbf9..10bdc84c21c 100644 --- a/src/main/runtime/terminal-list-execution-host-scope.test.ts +++ b/src/main/runtime/terminal-list-execution-host-scope.test.ts @@ -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' } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index c0cd2f9fb45..b66b117a556 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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[] }