Files
orca/src/main/runtime/runtime-pty-controller-contract.ts
T
Brennan Benson 8e5992663b A terminal pane that loses its reconnect to another connection attaches to the winner instead of showing terminal_pane_owner_changed (#30448)
* fix(terminal): a pane that loses its attach to another connection attaches to the winner

When a terminal pane reconnected to a saved process that had exited, and
another writer re-bound the pane meanwhile (most often the window's own
session save), main threw terminal_pane_owner_changed. The window showed it
as a raw red banner over a pane that was otherwise working, and nothing
cleared it.

Main now re-resolves the pane's owner once and attaches to it, with fresh
absence evidence even when only the saved binding changed. Retiring a dead
id's binding is fenced on its incarnation so it can never clear a newer
process that reused the id. A second loss is still reported; the window
asks once more for a local pane (never a fresh launch) and otherwise shows
the existing reconnect banner with Retry instead of the raw code.

Tracked in STA-4139.

* refactor(terminal): move the stable-pane attach step into its own file (max-lines)

* fix(terminal): fence an absent owner's retirement on an explicit incarnation, not the binding's own field

* Complete bounded pane ownership recovery through host publication

* Apply repository style rules to pane recovery changes

* fix: preserve terminal adoption type contracts

* fix: complete terminal recovery ownership and acceptance contracts

* fix: keep terminal registration evidence with its candidate

* fix: satisfy terminal recovery lint gates

* fix: consolidate terminal recovery imports

* test: explain bounded terminal fixture assertions

* test: handle optional pane binding maps in assertions

* fix: scope remote pane ownership refresh to recovery epoch

* fix: reject remote pane replies after recovery epoch ends

* Fix static checks for pane recovery

* Reuse session partition assignment in binding retirement

* fix: acquire returned terminal receipt before rejecting early exit

* Release superseded terminal spawn preparation before retry

* test: type preparation cleanup callbacks as functions

* test: initialize telemetry classifier in preparation collision cases

* test: identify preparation resources in reentry assertions

* fix(terminal): let a recovered pane replace the binding its host proved dead

Reopening an agent terminal whose process had exited showed "Orca couldn't
verify this terminal's owner", and the next relaunch lost the tab. The pane
operation proved the old PTY absent, but a window session save wrote that dead
binding back before the fresh commit, and the commit treated it as a rival
owner. The refused candidate was then shut down without an intentional-stop
mark, so its exit retired the whole tab.

The operation now carries its absence proof into the fresh launch, and both the
pre-launch owner check and the commit accept a replay of that dead binding. A
different or unproven owner is still contention. Discarded candidates are
marked as replaced, so their exit keeps the tab.

* test(terminal): type the discard test's runtime mocks

* fix: expose reconnect for retained pane recovery

* test: expect replaced stop for discarded fresh terminals

* fix(terminal): type the absent owner's retirement fence after the main merge

* test(terminal): follow main's Reconnect handler in the pane binding ownership test
2026-10-20 19:09:03 -07:00

218 lines
8.1 KiB
TypeScript

import type { PaneSpawnOperation } from '../ipc/pty/pane/pane-spawn-operation'
import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime'
import type {
AutomaticWorkResumeOutcome,
AutomaticWorkStatus,
SpawnIntent
} from '../../shared/host-spawn-admission'
import type {
AgentSessionClaimedSpawnResult,
AgentSessionExecutionClaim,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { TuiAgent } from '../../shared/tui-agent'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyBindingSourceExpectation } from '../persistence'
import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
PtyProviderBufferSnapshot,
PtyProcessInfo,
PtySpawnResult
} from '../providers/pty-provider-contract'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
export type PtyInventoryRefreshOptions = {
includeForegroundProcessEvidence?: boolean
refreshForegroundAgents?: boolean
}
export type RuntimePtyController = {
claimStablePaneCreate?(args: {
worktreeId: string
connectionId: string | null
tabId: string
leafId: string
}): () => void
adoptStablePane?(opts: {
signal?: AbortSignal
spawnFresh?: (operation: PaneSpawnOperation) => Promise<PtySpawnResult>
cols: number
rows: number
cwd?: string
connectionId: string | null
worktreeId: string
preAllocatedHandle: string
tabId: string
leafId: string
}): Promise<{
result: PtySpawnResult
owner: {
handle?: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
}
materialized?: true
} | null>
spawn?(opts: {
cols: number
rows: number
cwd?: string
command?: string
launchAgent?: TuiAgent
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
env?: Record<string, string>
envToDelete?: string[]
resumeProviderSession?: AgentProviderSessionMetadata
telemetry?: WorktreeStartupLaunch['telemetry']
connectionId?: string | null
worktreeId?: string
preAllocatedHandle?: string
tabId?: string
leafId?: string
sessionId?: string
/** Windows shell to spawn AS this PTY, instead of the host default. */
shellOverride?: string
/** The agent launch's runtime fact; when present the PTY shell comes only from it. */
launchRuntime?: AgentLaunchRuntime
isNewSession?: boolean
/** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */
initiallyHidden?: boolean
persistHostSessionBinding?: boolean
expectedSourceBinding?: PtyBindingSourceExpectation
placement?: TerminalPanePlacement
terminalKittyKeyboardProtocol?: boolean
terminalColorQueryReplies?: { foreground?: string; background?: string }
agentSessionEnsure?: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
agentSessionCreateOperationId?: string
spawnIntent?: SpawnIntent
signal?: AbortSignal
refuseSleptWorktree?: boolean
onPtySpawnCommitted?: () => void
paneSpawnOperation?: PaneSpawnOperation
adoptedStablePane?: {
result: PtySpawnResult
owner: {
handle?: string
tabId: string
leafId: string
ptyId: string
incarnationId?: string
}
materialized?: true
}
}): Promise<{
id: string
pid?: number | null
incarnationId?: PtyIncarnationId
wslDistro?: string
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
agentSessionEnsure?: AgentSessionClaimedSpawnResult
}>
/** The local terminal owner's automatic-restore state; null when it has no fork gate. */
getAutomaticWorkStatus?(): Promise<AutomaticWorkStatus | null>
resumeAutomaticWork?(): Promise<AutomaticWorkResumeOutcome>
write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean
/** Three-valued settlement; local providers settle synchronously. */
writeWithSettlement?(
ptyId: string,
data: string,
inputKind: TerminalInputKind
): WriteSettlement | Promise<WriteSettlement>
/** Attach-only adoption of a live local daemon session so its output streams
* to main without a renderer pane; never creates, resizes, or focuses.
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
attach?(ptyId: string): Promise<boolean>
kill(ptyId: string): boolean
/** `incarnationId` is the rejected spawn's; a same-id respawn since then is left alone. */
retireRejectedPty?(ptyId: string, stopConfirmed: boolean, incarnationId: string | undefined): void
stopAndWait?(
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
): Promise<boolean>
getCwd?(ptyId: string): Promise<string | null>
getForegroundProcess(ptyId: string): Promise<string | null>
inspectProcess?(
ptyId: string,
options?: { expectedIncarnationId?: PtyIncarnationId; scanChildProcesses?: boolean }
): Promise<PtyProcessInspection>
confirmForegroundProcess?(ptyId: string): Promise<string | null>
confirmShellForeground?(ptyId: string): Promise<boolean>
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
resetInputModes?(ptyId: string): Promise<void>
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?(
hostId?: ExecutionHostId,
opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean }
): Promise<PtyProcessInfo[]>
listProcessesWithHostScope?(opts?: {
deadlineMs?: number
includeForegroundProcessEvidence?: boolean
}): Promise<{
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}>
supportsForegroundProcessEvidence?(hostId?: ExecutionHostId): Promise<boolean>
serializeBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number }
): Promise<{
data: string
cols: number
rows: number
seq?: number
lastTitle?: string
kittyKeyboardFlags?: number
} | null>
/** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */
serializeProviderBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number }
): Promise<PtyProviderBufferSnapshot | null>
// Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip
// hydration when no renderer is authoritative for this PTY. See
// docs/mobile-prefer-renderer-scrollback.md.
hasRendererSerializer?(ptyId: string): boolean
getRendererSerializerGeneration?(ptyId: string): number
waitForRendererSerializer?(
ptyId: string,
afterGeneration: number,
timeoutMs?: number,
signal?: AbortSignal
): Promise<boolean>
getSize?(ptyId: string): { cols: number; rows: number } | null
/** False only when the owning provider proved the PTY absent; null = unknown (never a denial). */
probePtyLiveness?(ptyId: string): Promise<boolean | null>
}
export type PtyControllerTerminalIdentity = Readonly<{
handle: string
incarnationId: string
wslDistro?: string | null
}>
export type PtyControllerInventory = Readonly<{
livePtyIds: ReadonlySet<string>
// Why: livePtyIds is worktree-scoped when a target is given; absence proofs
// must consult the unscoped inventory or a misattributed live PTY reads as dead.
allLivePtyIds: ReadonlySet<string>
terminalIdentityByPtyId: ReadonlyMap<string, PtyControllerTerminalIdentity>
queriedHostIds: ReadonlySet<ExecutionHostId>
/** Live PTYs the host is killing: still live work, but no longer any pane's to show. */
exitingPtyIds?: ReadonlySet<string>
}>