Files
orca/src/main/runtime/orca-runtime-core.ts
T
Brennan Benson 78ad9f4255 fix: keep chat option actions independent of inventory (#31927)
* fix: separate chat option input from inventory observations

* fix: preserve scoped chat choices and publication ownership

* fix: complete scoped catalog and applied control ownership

* test: make catalog fixture dependency boundaries explicit

* test: request catalog discovery explicitly in demand contracts

* fix(chat): preserve option observations and operation boundaries

* fix(chat): finish pending option and catalog lifetimes

* fix(chat): project saved permissions without launch preparation

* fix(chat): correct option rejection and catalog retirement

* Preserve Claude effort validation advice through chat rejection

* test: align refresh mocks and preserve fixture type evidence

* fix: share catalog claims only across equivalent owners

* test: use typed catalog corpus instrumentation

* fix: consolidate structured launch imports

* Validate SDK queue regression envelopes without assertions

* fix: complete catalog runtime and fixture type contracts

* Align native option fixtures with explicit discovery and applied facts

* Keep initial compatibility catalog reads on held memory

* Complete the installed catalog fixture envelope

* Observe learned defaults after asynchronous projection settles

* Consolidate host fixture journal type imports

* test(native-chat): align goal and seed fixtures with applied inputs

* fix(codex): retain applied proof when goal settings skip a write

* test: load rejection wording before timed queue regression

* Fix catalog fixtures and queued option snapshot typing

* Align permission publication fixture with passive option reads

* Separate startup config from held turn permission fixture

* Align input lifecycle fixtures with sparse applied choices

* Simplify scoped catalog refresh ownership and coalesce cache snapshots

* Load catalog persistence real-module fixture during collection

* Compose catalog availability fixtures from valid session records

* Align catalog and startup fixtures with current option contracts

* Fix permission read types and separate catalog fixture preparation

* Align option and restart fixtures with current lifecycle owners

* test(mobile): retain reported model alongside discovery

* test(mobile): recalibrate the measured route script envelope

* test: let the preflight shell-capture mock run its detection callback
2026-10-28 16:08:01 -07:00

305 lines
9.5 KiB
TypeScript

import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import type { RuntimeTerminalRowMetadata } from '../../shared/runtime-terminal-contracts'
import { IMMEDIATE_KILL_REPLY_BUDGET_MS } from '../daemon/immediate-kill-reply-budget'
import type {
RuntimeTerminalDriverState,
RuntimeTerminalPresentation
} from '../../shared/runtime-types'
import type { RuntimeEdgeCommandSurface } from './runtime-edge-command-controller'
import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface'
import type { RuntimeFileCommandSurface } from './runtime-file-command-surface'
import type { RuntimeGitCommandSurface } from './runtime-git-command-surface'
import type { RuntimeRepositoryCommandSurface } from './runtime-repository-command-surface'
import type { RuntimeReviewCommandSurface } from './runtime-review-command-surface'
import type { RuntimeServiceCommandSurface } from './runtime-service-command-surface'
import type { RuntimeSkillCommandSurface } from './runtime-skill-command-contract'
export type PtyIncarnationHandleRecord = {
handle: string
incarnationId: string | null
leafKey: string
}
export type RuntimeWorktreeScanCache = {
generation: number
runtimeKey: string
result: RuntimeWorktreeScanResult
listing?: RuntimeWorktreeScanResult
expiresAt: number
adminFingerprint: string | null
scannedAt: number
}
export type RuntimeWorktreeScanInFlight = {
generation: number
runtimeKey: string
promise: Promise<RuntimeWorktreeScanRefresh>
}
export type RuntimeWorktreeScanRefresh = {
result: RuntimeWorktreeScanResult
adminFingerprint: string | null
adminFingerprintProbe: Promise<string | null> | null
scannedAt: number
}
export type ResolvedTerminalWorkspaceLaunchTarget = {
scope: TerminalWorkspaceLaunchScope
managedWorktree: ResolvedWorktree | null
}
// Allow lost-response reconnect retries for one minute; later intentional resumes fork fresh.
export const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000
// Connection-migration retries reuse clientMutationId to avoid duplicate workspaces.
export const WORKTREE_CREATE_RESULT_TTL_MS = 60_000
export const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
// Rejected splits need only a quick teardown verdict before fallback kill; unreachable hosts must not stall rejection.
export const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
// Include immediate-kill and follow-up inventory budgets; agent exit hooks can delay verification.
export const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = IMMEDIATE_KILL_REPLY_BUDGET_MS + 2_000
export const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
export const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
// Claude shows its cursor before composer rendering settles.
export const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
export const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
export const SSH_PANE_RECOVERY_GRACE_MS = 30_000
// Cache proven absence across keystroke bursts, but let recreated session ids recover promptly.
export const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000
export const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000
export type RuntimeTerminalProjection = {
lines: string[]
draft?: string
tailRows?: RuntimeTerminalRowMetadata[]
}
export function assertAgentPromptRequestActive(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('request_aborted')
}
}
export async function waitForAgentPromptPromise<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
if (!signal) {
return await promise
}
assertAgentPromptRequestActive(signal)
return await new Promise<T>((resolve, reject) => {
let settled = false
const finish = (result: { value: T } | { error: unknown }): void => {
if (settled) {
return
}
settled = true
signal.removeEventListener('abort', onAbort)
if ('error' in result) {
reject(result.error)
} else {
resolve(result.value)
}
}
const onAbort = (): void => finish({ error: new Error('request_aborted') })
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
return
}
promise.then(
(value) => finish({ value }),
(error: unknown) => finish({ error })
)
})
}
export async function waitForAgentPromptDelay(
delayMs: number,
signal?: AbortSignal
): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
return
}
assertAgentPromptRequestActive(signal)
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(new Error('request_aborted'))
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, delayMs)
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
}
})
}
export function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null {
let last: { start: number; end: number } | null = null
let searchFrom = 0
while (searchFrom < data.length) {
const start = data.indexOf('\x1b]', searchFrom)
if (start === -1) {
break
}
const command = data[start + 2]
if ((command !== '0' && command !== '1' && command !== '2') || data[start + 3] !== ';') {
searchFrom = start + 2
continue
}
let cursor = start + 4
for (; cursor < data.length; cursor += 1) {
if (data[cursor] === '\x07') {
last = { start, end: cursor + 1 }
searchFrom = cursor + 1
break
}
if (data[cursor] !== '\x1b') {
continue
}
if (data[cursor + 1] === '\\') {
last = { start, end: cursor + 2 }
searchFrom = cursor + 2
} else {
searchFrom = cursor
}
break
}
if (cursor === data.length) {
break
}
}
return last
}
export function isClientDisconnectedError(error: unknown): boolean {
return error instanceof Error && error.message === 'client_disconnected'
}
export function createTerminalRevealWarning(handle: string, error?: unknown): string {
const reason =
error instanceof Error && error.message.trim().length > 0
? ` Reason: ${error.message.trim()}.`
: ''
return [
`Terminal ${handle} is running, but Orca could not make it discoverable.${reason}`,
`Run \`orca terminal focus --terminal ${handle}\` to reveal and focus it.`
].join(' ')
}
// Absence means default; surfacing callers must omit surfaceOwner instead of sending true.
export function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } {
return shouldSurface ? {} : { surfaceOwner: false }
}
export function resolveTerminalPresentation(opts: {
presentation?: RuntimeTerminalPresentation
focus?: boolean
activate?: boolean
}): RuntimeTerminalPresentation | undefined {
if (opts.presentation) {
return opts.presentation
}
if (opts.focus === true || opts.activate === true) {
return 'focused'
}
return undefined
}
export function addListenerToMap<T>(
map: Map<string, Set<T>>,
key: string,
listener: T
): () => void {
let listeners = map.get(key)
if (!listeners) {
listeners = new Set<T>()
map.set(key, listeners)
}
const set = listeners
set.add(listener)
return () => {
set.delete(listener)
if (set.size === 0) {
map.delete(key)
}
}
}
export const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_PORT',
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_TRANSPORT',
'ORCA_AGENT_HOOK_ENDPOINT'
] as const
export type RuntimeWorktreeLifecycleEvent =
| { kind: 'created'; worktreeId: string; path: string; branch: string }
| { kind: 'removed'; worktreeId: string; path: string }
// One driver per PTY: idle has no subscriber; desktop has passive/reclaimed subscribers.
// mobile owns input/resize and blocks desktop writes; see docs/mobile-presence-lock.md.
export type DriverState = RuntimeTerminalDriverState
// Desktop uses renderer geometry while phones watch passively; phone uses its owner viewport and suppresses desktop fit.
// See docs/mobile-terminal-layout-state-machine.md.
export type PtyLayoutTarget =
| { kind: 'desktop'; cols: number; rows: number }
| { kind: 'phone'; cols: number; rows: number; ownerClientId: string }
| { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string }
// Increment on successful layout changes so clients reject stale transitions.
export type PtyLayoutState = PtyLayoutTarget & {
seq: number
appliedAt: number
}
// Only successful changes own a new seq; pty-exited is terminal, resize-failed retryable.
export type ApplyLayoutResult =
| { ok: true; state: PtyLayoutState }
| { ok: false; reason: 'pty-exited' | 'resize-failed' }
export type LayoutQueueEntry = {
running: Promise<ApplyLayoutResult> | null
pending: {
target: PtyLayoutTarget
waiters: ((r: ApplyLayoutResult) => void)[]
}[]
}
export type RuntimeInstalledCommandSurfaces = RuntimeEdgeCommandSurface &
RuntimeLinearCommandSurface &
RuntimeFileCommandSurface &
RuntimeGitCommandSurface &
RuntimeRepositoryCommandSurface &
RuntimeReviewCommandSurface &
RuntimeServiceCommandSurface &
RuntimeSkillCommandSurface
export type RuntimeCommandSurfaceHost<T> = T & RuntimeInstalledCommandSurfaces
export type RuntimeRendererReloadFence = Readonly<{
revision: number
recovery: 'renderer' | 'headless' | 'reloading'
}>