Files
orca/src/main/runtime/orca-runtime-core.ts
T
Neil a5796ec8eb refactor(runtime): split OrcaRuntimeService and compatibility tests (#17605)
* refactor(runtime): split OrcaRuntimeService into focused modules

* test(runtime): cover admission tiers and strict worktree reconciliation

* fix(runtime): preserve owner and structured session visibility

* fix(runtime): port post-extraction compatibility fixes

* fix(runtime): preserve skill-share cancellation barrier

* test(runtime): update identity inventory after extraction

* fix(runtime): preserve hook transport environment cleanup

* fix(runtime): consolidate idle probe imports

* test(runtime): retire split file process allowlist entry

* fix(runtime): route child process types through shared boundary

* test(runtime): preserve worktree host metadata precedence

* fix(runtime): update extracted test seams

* fix(runtime): gate the split's ts-nocheck set and restore the stop-confirmed contract

Audit follow-ups for the OrcaRuntimeService split:

- Freeze the 171 @ts-nocheck files behind a ratchet so no new file can disable
  type checking. The split's linear mixin chain cannot express forward
  references yet, so the existing suppressions are grandfathered; the baseline
  may only shrink.
- Drop the stray @ts-nocheck at the end of orca-runtime-get-status.ts. It sat
  after the first statement, where TypeScript ignores it, so the module was
  already checked.
- Restore `retireRejectedPty(ptyId, stopConfirmed: boolean)` as a required
  argument. The split widened it to optional and patched the resulting error
  with `stopConfirmed === true`; an omitted argument would have silently taken
  the unverified-stop path instead of failing to compile.
- Guard that every orca-runtime-tests fragment is imported by the compatibility
  entrypoint. The fragments are .spec.ts, which no Vitest include glob matches,
  so one left out of the list would silently stop running.

* fix(runtime): restore four behaviors the OrcaRuntimeService split dropped

Audit findings against the refactor's true base (ad5ba2572e):

- retirePtyAgentLaunchAuthority collected pane keys after deleting the
  restored-authority receipt instead of before it. collectPaneKeysForPty reads
  that receipt, so a receipt-only pane lost its key and never had its agent-hook
  compatibility authority retired. on-pty-exit.ts already carried a comment
  naming this exact invariant.
- The PTY-exit path kept orchestrationMailboxNotifications.retirePty but lost
  the loop that schedules a debounced mail-pointer repoint for the dead pty's
  terminal handle and any run bound to its panes. Restores the schedule call
  count to 7, matching base.
- subscribeToPtyExit lost isPtyKnownExited's leaf fallback and its
  post-registration lifecycle-generation recheck. leavesByPtyId is rebuilt from
  the renderer graph independently of ptysById, so a leaf can outlive its pty
  record; without the fallback a caller waiting on an already-dead pty never
  gets released.
- The chain root declared `[key: string]: unknown`, which base had nowhere. It
  leaked through the exported runtime type into every consumer, so any misspelled
  member access typechecked as unknown instead of erroring, and it accounted for
  957 of the suppressed errors. Removing it costs zero type errors.

* fix(runtime): restore escalation prose and unscoped automation publication

Two more behaviors the split dropped, each with a regression test that fails
against the pre-fix code:

- The worker-exit escalation stopped deriving its title through
  buildOrchestrationTaskDisplayMetadata and inlined `task.spec` instead. That
  ignored an explicit task_title, dropped the single-line normalization and the
  80-character bound, and turned the no-spec case into a quoted, duplicated id.
  A multi-paragraph spec landed verbatim in the coordinator's banner. The
  existing 11 tests all use short single-line specs, where the derived title and
  the raw spec are identical, so none of them could see it.
  Also reverts an added `if (!handle) return` guard: the dispatch lookup is
  deliberately keyed on the pane as well, because a reminted handle no longer
  matches the row while the pane identity outlives the remint.
- updateAutomation stopped going through automationChangePublications and
  published `source` unconditionally while gating the fallback on a non-null
  destination. A destination the store can no longer name then published only
  the stale source, so subscribers scoped elsewhere kept rendering a row that
  had left them — the exact case the helper documents. The helper had been left
  with zero callers; all three sites use it again.

* fix(skills): stop swallowing lookup errors and hard-erroring on non-ssh hosts

Follow-ups from auditing the skill install path against the refactor's base:

- resolveWorktree wrapped showManagedWorktree in `.catch(() => null)`, so a
  transient git or IO failure surfaced to the user as
  skill-install-workspace-not-found with the real cause discarded. Errors
  propagate again; a genuine id mismatch still returns null.
- resolveSkillSshTarget threw skill-install-workspace-host-unavailable when the
  execution host was neither local nor ssh, on both the repo and folder
  branches. Base gated these on connectionId, so a runtime-owned repo simply
  was not an SSH install and fell through to the local path. Both return null
  again, and the error code the split invented is now unreferenced.
- listManagedSkillInstalls awaited the receipt walk and the worktree resolve in
  sequence. They are independent and either can hit disk, WSL, or an SSH scan,
  so Promise.all is restored.

Deliberately unchanged: resolving the worktree through listResolvedWorktrees
rather than showManagedWorktree, which disambiguates a worktree id colliding
across hosts and is covered by its own test, and the SSH-folder
skill-install-ssh-dispatch-required throw, which matches the repo branch.

* fix(runtime): merge duplicate worktree-logic imports

The #17448 port added a third import from ../ipc/worktree-logic, which the
code-quality oxlint config rejects under --deny-warnings. Plain oxlint does not
flag it, so it only surfaced in CI's static analysis job.

* ci: run the ts-nocheck ratchet in PR checks

pr-workflow-lint-parity requires every leaf command in `pnpm lint` to have a
matching step in pr.yml. The ratchet was wired into lint but not the workflow,
so PR CI would not have enforced it.

* Merge remote-tracking branch 'origin/main' and retry the paired-host launch evaluate

main advanced 9 commits; none touch the orca-runtime.ts this branch splits, so
nothing needed porting.

CI failed twice on `Execution context was destroyed` thrown from
headless-paired-runtime-host's first `evaluate` after launch — a different spec
each run, which is the signature of the flake #17780 describes rather than a
regression. That commit added retryTransientMainEvaluate and adopted it in five
helpers but not this call site, even though its docblock names exactly this
case: the first evaluate after electron.launch() resolves, before the app is
ready. Wrapped it the same way.
2026-08-31 19:34:55 -07:00

356 lines
12 KiB
TypeScript

// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
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 { RuntimeLeafRecord } from './runtime-terminal-state-records'
import { isCursorAgentTitle } from '../../shared/agent-detection'
import { isAbsolute, relative, resolve } from 'node:path'
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-surface'
export type PtyIncarnationHandleRecord = {
handle: string
incarnationId: string | null
leafKey: string
}
export type RuntimeWorktreeScanCache = {
generation: number
runtimeKey: string
result: 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
}
export function isCursorAgentOrchestrationTarget(
leaf: RuntimeLeafRecord,
tabTitle: string | null | undefined
): boolean {
return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle)
}
export const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512
export const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096
// Why: long enough for a phone to reconnect and retry a create whose response
// was lost, short enough that an intentional later re-resume forks fresh.
export const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000
// Why: same idempotency window for worktree.create — a phone whose create was
// interrupted by a connection migration retries with the same clientMutationId
// and reuses the just-created worktree instead of spawning a duplicate.
export const WORKTREE_CREATE_RESULT_TTL_MS = 60_000
export const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
// Why: the split already failed; the caller waits on this teardown only to learn whether the
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
export const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
export const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = 2_000
export const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
export const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
// Why: Claude emits show-cursor while rendering its composer; output must settle afterward.
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
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
// short enough that a recreated session id regains writability quickly even if
// its runtime record (which also invalidates the verdict) is late.
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 }
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 })
)
})
}
// Generic terminal.send uses setImmediate to let abort/permission/data callbacks run between
// chunks without paying a full Windows timer tick for every 16 KiB write. Agent prompts use an
// atomic bracketed-paste write, so they do not rely on this scheduler.
// Why the global and not node:timers/promises: only the global is intercepted by fake timers,
// so a chunked paste stays observable on the test clock.
export function yieldBetweenTerminalInputChunks(): Promise<void> {
return new Promise<void>((resolve) => {
setImmediate(resolve)
})
}
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(' ')
}
// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit
// the key rather than send `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
}
// Subscribe a listener to a per-key Set, pruning the key's entry once its last
// listener unsubscribes. Returns the unsubscribe callback.
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 function isPathWithinDirectory(directory: string, candidate: string): boolean {
const relativePath = relative(resolve(directory), resolve(candidate))
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
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
// Why: notificationSeq is the desktop-assigned monotonic sequence used for
// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a
// client can watermark the last event it delivered and request exactly the
// events after it — idempotent, no duplicate local pushes.
export type RuntimeWorktreeLifecycleEvent =
| { kind: 'created'; worktreeId: string; path: string; branch: string }
| { kind: 'removed'; worktreeId: string; path: string }
// Why: presence-based driver state for the mobile-presence lock. Exactly one
// driver per PTY at any moment. See docs/mobile-presence-lock.md.
// - `idle`: no mobile subscribers; desktop input flows freely
// - `desktop`: at least one mobile client subscribed but desktop reclaimed
// (or all mobile clients are passive `desktop`-mode watchers); desktop
// input flows freely
// - `mobile{clientId}`: a mobile client is the active driver; desktop
// input/resize are dropped server-side and the lock banner is mounted.
// `clientId` is the most recent mobile actor for this PTY.
export type DriverState = RuntimeTerminalDriverState
// Why: per-PTY layout target — what the PTY *should* be at right now.
// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive
// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at
// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed.
// 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 }
// Why: authoritative layout state with monotonic seq. Bumped on every
// applyLayout success; emitted on mobile subscribe-stream events so clients
// drop stale events that arrive after a newer transition.
export type PtyLayoutState = PtyLayoutTarget & {
seq: number
appliedAt: number
}
// Why: applyLayout result discriminator. Callers (especially RPC handlers)
// need to distinguish "shipped a new state at seq N" from "no-op — caller
// should not claim a seq it didn't produce." `pty-exited` is terminal;
// `resize-failed` is transient and the caller may retry.
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'
}>