Files
orca/src/main/runtime/runtime-pty-controller-contract.ts
T
Neil 17ffbf3b31 fix(runtime): answer startup terminal queries for background-created terminals (#22384)
* fix(runtime): answer startup terminal queries for background-created terminals

A runtime-created terminal (orchestration worker-start, `orca terminal
create`) has no renderer pane until the user opens its tab. Main only
answers terminal queries for PTYs the renderer marked hidden, so on a
fresh app sitting on the landing screen nothing answered the agent's
startup cursor-position query. Muse waits ~2s per unanswered CPR and then
exits 0 with no output, which surfaced as `agent_readiness: timeout`.

Background runtime spawns now carry initiallyHidden, mirroring the
renderer's hidden-at-spawn path: fresh daemon sessions are marked before
byte zero, the committed id is marked and paced as backgrounded, and the
mark is released on failure, reattach, or adoption. A pane that later
mounts visible unmarks and restores from the model snapshot as before.

* fix(runtime): keep background PTYs hidden and paced across renderer reloads

A runtime background spawn has no renderer pane to report visibility or
re-mark it hidden, so it synced as foregrounded (no backpressure thinning)
and a reload/crash gate reset cleared its hidden mark, leaving startup
queries unanswered. Track runtime-owned hidden marks: they survive
renderer-scoped resets, count as known-hidden for backgrounded pacing
until a visible report, and are released by a renderer unmark or PTY
teardown.

* fix(runtime): don't re-hide a background PTY whose view mounted visible during spawn
2026-09-22 23:00:11 -07:00

186 lines
6.7 KiB
TypeScript

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 { ExecutionHostId } from '../../shared/execution-host'
import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
export type RuntimePtyController = {
claimStablePaneCreate?(args: {
worktreeId: string
connectionId: string | null
tabId: string
leafId: string
}): () => void
adoptStablePane?(opts: {
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
isNewSession?: boolean
/** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */
initiallyHidden?: boolean
persistHostSessionBinding?: boolean
expectedSourceBinding?: PtyBindingSourceExpectation
terminalKittyKeyboardProtocol?: boolean
terminalColorQueryReplies?: { foreground?: string; background?: string }
agentSessionEnsure?: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
agentSessionCreateOperationId?: string
signal?: AbortSignal
onPtySpawnCommitted?: () => void
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
}>
write(ptyId: string, data: string): boolean
writeAgentSessionProof?(
ptyId: string,
data: string,
authority: { sessionId: string; spawnToken: string }
): boolean
/** Three-valued settlement; local providers settle synchronously. */
writeWithSettlement?(ptyId: string, data: string): 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
retireRejectedPty?(ptyId: string, stopConfirmed: boolean): void
stopAndWait?(
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
): Promise<boolean>
markReversibleStops?(ptyIds: readonly string[]): () => void
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>
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
listProcesses?(
connectionId?: string | null,
opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean }
): Promise<PtyProcessInfo[]>
listProcessesWithHostScope?(opts?: {
deadlineMs?: number
includeForegroundProcessEvidence?: boolean
}): Promise<{
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}>
supportsForegroundProcessEvidence?(connectionId?: string | null): 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>
}>