mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip) The fixes found while the sidebar ran from main's feed, without turning that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's stop-gaps come along. - Main stops counting a closed tab's terminal once it was asked to stop and no pane shows it; a failed or refused stop takes the request back, and a host out of contact stays unverifiable. - Main fetches a paired server's rows with the transport's normal bound, so a server slower than 5 s still lists. - A deferred unread raise flips the folder row it was decided for, and a read while the owner check is out cancels it. - Phone: no worktree.ps flash before the feed; a host's last installed feed stands in while a fresh read is out; it highlights its own last-opened workspace and a fold never scrolls; projects file under their project group with the desktop's indent; the collapse spinner shows only after 500 ms; "Reopen to update" keeps the rows on screen. - e2e helpers and journeys for the switch-on suite. * fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises - A stop-and-wait that throws takes its stop request back, like one that fails. - A final stop the legacy relay still owes stays marked, since the relay retries it. - Pending unread raises are tracked only while one waits, so the map no longer grows. - The paired-server fallback poll test gives its third fetch a reply and asserts live.
336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
import { OrcaRuntimeWithOnPtyExit } from '../lifecycle/pty-exit-lifecycle'
|
|
import type { PtyLivenessVerdict } from '../../../../shared/pty-liveness-verdict'
|
|
import type { DriverState } from '../../orca-runtime-core'
|
|
import type { TerminalInputKind } from '../../../../shared/terminal-input-kind'
|
|
import { isUntypedTerminalInput } from '../../../../shared/terminal-untyped-input'
|
|
import { clampTerminalViewport } from '../../../../shared/terminal-viewport'
|
|
import { getPtyTerminalState, getTerminalState } from '../../terminal-wait-results'
|
|
|
|
// Orphaned verdicts are bounded; active PTYs retain theirs until new evidence resolves them.
|
|
const MAX_TRACKED_PTY_LIVENESS_VERDICTS = 256
|
|
|
|
export abstract class OrcaRuntimeWithMarkPtyLivenessUnverifiable extends OrcaRuntimeWithOnPtyExit {
|
|
protected abstract leafExistsForPty(ptyId: string): boolean
|
|
abstract recordRendererGeometry(ptyId: string, cols: number, rows: number): void
|
|
abstract isResizeSuppressed(): boolean
|
|
protected abstract refreshRendererGeometry(ptyId: string, cols: number, rows: number): void
|
|
|
|
/**
|
|
* Records that we lost contact with a PTY's owning host. Callers must never
|
|
* read this as an exit: a detached relay PTY is designed to outlive the
|
|
* provider that addressed it.
|
|
*/
|
|
markPtyLivenessUnverifiable(ptyId: string, reason: string): void {
|
|
this.rememberPtyLivenessVerdict(ptyId, { status: 'unverifiable', reason })
|
|
}
|
|
|
|
/**
|
|
* A host positively observed this PTY. `observedNoLaterThan` fences the write against the
|
|
* observation sequence the caller read at, so a slow in-flight listing cannot overwrite a
|
|
* newer lost-contact verdict recorded while it was outstanding.
|
|
*/
|
|
markPtyLivenessLive(ptyId: string, observedNoLaterThan?: number): void {
|
|
const tracked = this.ptyLivenessVerdictByPtyId.get(ptyId)
|
|
if (observedNoLaterThan !== undefined && tracked && tracked.observedAt > observedNoLaterThan) {
|
|
return
|
|
}
|
|
if (tracked?.verdict.status === 'unverifiable') {
|
|
void this.recheckHookAgentPresenceForPty(ptyId)
|
|
}
|
|
this.rememberPtyLivenessVerdict(ptyId, { status: 'live', ptyIds: [ptyId] })
|
|
}
|
|
|
|
/**
|
|
* Records that Orca asked this PTY to stop — a close, a stop, a teardown.
|
|
*
|
|
* Why before the kill and not at the exit: a requested stop can still be
|
|
* delivered by the provider's own exit event, which carries a process status
|
|
* indistinguishable from a natural finish. The intent is the only thing that
|
|
* separates "the operator closed it" from "the agent died", so it is recorded
|
|
* where it is known rather than reconstructed afterwards (STA-4603).
|
|
*/
|
|
markPtyStopRequested(ptyId: string): void {
|
|
this.stopRequestedPtyIds.add(ptyId)
|
|
}
|
|
|
|
/** A stop that failed or was refused: the PTY runs on, so nothing may read it as closing. */
|
|
clearPtyStopRequest(ptyId: string): void {
|
|
this.stopRequestedPtyIds.delete(ptyId)
|
|
}
|
|
|
|
isPtyStopRequested(ptyId: string): boolean {
|
|
return this.stopRequestedPtyIds.has(ptyId)
|
|
}
|
|
|
|
/**
|
|
* Null when this register holds no defensible claim — a never-asked host, a fresh app start, or
|
|
* an absence observation too weak to name (a relay that answered but does not know the id: see
|
|
* the inventory sweep and handlePtyReattachFailure). It is NOT a death certificate, so a caller
|
|
* authorizing a kill must fail closed on it; a caller that only has this evidence to work with,
|
|
* like terminal.recoverPane, refuses on the positive verdicts instead.
|
|
*/
|
|
getPtyLivenessVerdict(ptyId: string): PtyLivenessVerdict | null {
|
|
return this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict ?? null
|
|
}
|
|
|
|
protected isPtyKnownExited(ptyId: string): boolean {
|
|
const pty = this.ptysById.get(ptyId)
|
|
if (pty) {
|
|
// Why: `!connected` is an inference, not proof. The liveness sweep clears it with no
|
|
// exit code for every PTY of a dropped relay, so reading that as an exit retires the
|
|
// lease of a process still running on the host — 'unknown' must keep watching.
|
|
return getPtyTerminalState(pty) === 'exited'
|
|
}
|
|
// Why: leavesByPtyId is rebuilt from the renderer graph independently of ptysById, so a
|
|
// leaf can outlive (or precede) its pty record; without this an already-dead pty never
|
|
// fires and the caller's release waits forever.
|
|
return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited')
|
|
}
|
|
|
|
subscribeToPtyExit(ptyId: string, listener: () => void): () => void {
|
|
const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId)
|
|
if (this.isPtyKnownExited(ptyId)) {
|
|
listener()
|
|
return () => {}
|
|
}
|
|
let listeners = this.ptyExitListenersByPtyId.get(ptyId)
|
|
if (!listeners) {
|
|
listeners = new Set()
|
|
this.ptyExitListenersByPtyId.set(ptyId, listeners)
|
|
}
|
|
let active = true
|
|
const unsubscribe = (): void => {
|
|
if (!active) {
|
|
return
|
|
}
|
|
active = false
|
|
listeners!.delete(listener)
|
|
if (listeners!.size === 0 && this.ptyExitListenersByPtyId.get(ptyId) === listeners) {
|
|
this.ptyExitListenersByPtyId.delete(ptyId)
|
|
}
|
|
}
|
|
listeners.add(listener)
|
|
if (
|
|
this.getPtyLifecycleGeneration(ptyId) !== lifecycleGeneration ||
|
|
this.isPtyKnownExited(ptyId)
|
|
) {
|
|
unsubscribe()
|
|
listener()
|
|
}
|
|
return unsubscribe
|
|
}
|
|
|
|
/** Resolves once a registered PTY's exit reaches its runtime record; false on timeout. */
|
|
waitForPtyExitRecord(ptyId: string, timeoutMs: number): Promise<boolean> {
|
|
if (!this.ptysById.has(ptyId) || this.isPtyKnownExited(ptyId)) {
|
|
return Promise.resolve(true)
|
|
}
|
|
return new Promise((resolve) => {
|
|
let unsubscribe = (): void => {}
|
|
const timer = setTimeout(
|
|
() => {
|
|
unsubscribe()
|
|
resolve(false)
|
|
},
|
|
Math.max(0, timeoutMs)
|
|
)
|
|
timer.unref?.()
|
|
unsubscribe = this.subscribeToPtyExit(ptyId, () => {
|
|
clearTimeout(timer)
|
|
resolve(true)
|
|
})
|
|
})
|
|
}
|
|
|
|
protected rememberPtyLivenessVerdict(ptyId: string, verdict: PtyLivenessVerdict): void {
|
|
// An earned death certificate is KEPT, not dropped, so the register is three-valued on disk as
|
|
// well as in the type. Its only writer is a host-delivered exit frame; nothing weaker may
|
|
// reach it (docs/reference/ssh-execution-boundary.md).
|
|
this.ptyLivenessVerdictByPtyId.delete(ptyId)
|
|
this.ptyLivenessObservationSequence += 1
|
|
this.ptyLivenessVerdictByPtyId.set(ptyId, {
|
|
verdict,
|
|
observedAt: this.ptyLivenessObservationSequence
|
|
})
|
|
while (this.ptyLivenessVerdictByPtyId.size > MAX_TRACKED_PTY_LIVENESS_VERDICTS) {
|
|
let oldestOrphaned: string | null = null
|
|
for (const candidate of this.ptyLivenessVerdictByPtyId.keys()) {
|
|
if (
|
|
!this.ptysById.has(candidate) &&
|
|
!this.handleByPtyId.has(candidate) &&
|
|
!this.leafExistsForPty(candidate)
|
|
) {
|
|
oldestOrphaned = candidate
|
|
break
|
|
}
|
|
}
|
|
if (!oldestOrphaned) {
|
|
return
|
|
}
|
|
this.ptyLivenessVerdictByPtyId.delete(oldestOrphaned)
|
|
}
|
|
}
|
|
|
|
protected forgetPtyLivenessVerdict(ptyId: string, observedNoLaterThan?: number): void {
|
|
const tracked = this.ptyLivenessVerdictByPtyId.get(ptyId)
|
|
// An inventory's weak absence cannot revoke an earlier host-certified exit.
|
|
if (tracked?.verdict.status === 'exited') {
|
|
return
|
|
}
|
|
if (observedNoLaterThan !== undefined && tracked && tracked.observedAt > observedNoLaterThan) {
|
|
return
|
|
}
|
|
this.ptyLivenessVerdictByPtyId.delete(ptyId)
|
|
}
|
|
|
|
// ─── Driver state (mobile-presence lock) ──────────────────────────
|
|
//
|
|
// See docs/mobile-presence-lock.md.
|
|
|
|
getDriver(ptyId: string): DriverState {
|
|
return this.terminalDrivers.get(ptyId)
|
|
}
|
|
|
|
protected setDriver(ptyId: string, next: DriverState): void {
|
|
this.terminalDrivers.set(ptyId, next)
|
|
}
|
|
|
|
// Why: the host's own fit cascade (window resize, split drag, tab reveal,
|
|
// "+"-new-tab re-render) must not resize a PTY whose width a remote client
|
|
// owns — that is the remote "porridge" bug. True while a phone (mobile driver)
|
|
// OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated
|
|
// here (see the `writePtyInput` mobile-only checks): shared-control desktop
|
|
// viewers may still type alongside the host.
|
|
// Note: this is intentionally NOT a driver kind. An active remote viewer needs
|
|
// only resize suppression, not the mobile driver machinery (input lock,
|
|
// phone-fit, driver-change banners), so it lives in its own registry and does
|
|
// not perturb the presence-lock state machine. It also coexists with mobile:
|
|
// while a phone drives, the registry still suppresses host resize, and when
|
|
// the phone leaves the surviving viewer keeps the PTY suppressed.
|
|
isPtyResizeDrivenRemotely(ptyId: string): boolean {
|
|
if (this.getDriver(ptyId).kind === 'mobile') {
|
|
return true
|
|
}
|
|
return this.isRemoteDesktopResizeDriven(ptyId)
|
|
}
|
|
|
|
isRemoteDesktopResizeDriven(ptyId: string): boolean {
|
|
return this.remoteDesktopFloor.isResizeDriven(ptyId)
|
|
}
|
|
|
|
isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean {
|
|
return this.remoteDesktopFloor.isViewerOwner(ptyId, subscriptionKey)
|
|
}
|
|
|
|
getRemoteDesktopFitHold(
|
|
ptyId: string,
|
|
subscriptionKey: string
|
|
): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } {
|
|
return this.remoteDesktopFloor.getFitHold(ptyId, subscriptionKey)
|
|
}
|
|
|
|
recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void {
|
|
this.remoteDesktopFloor.recordHostReclaimTarget(ptyId, cols, rows)
|
|
}
|
|
|
|
async applyRemoteDesktopLayout(ptyId: string): Promise<boolean> {
|
|
return this.remoteDesktopFloor.applyLayout(ptyId)
|
|
}
|
|
|
|
// Why: attachment only records geometry. Passive hydration/reconnect must not
|
|
// steal the shared PTY from the desktop where the user is actively working.
|
|
async updateRemoteDesktopViewer(
|
|
ptyId: string,
|
|
subscriptionKey: string,
|
|
clientId: string,
|
|
cols: number,
|
|
rows: number,
|
|
claim = true
|
|
): Promise<boolean> {
|
|
return this.remoteDesktopFloor.updateViewer(ptyId, subscriptionKey, clientId, cols, rows, claim)
|
|
}
|
|
|
|
claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise<boolean> {
|
|
return this.remoteDesktopFloor.claimViewer(ptyId, subscriptionKey)
|
|
}
|
|
|
|
claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise<boolean> {
|
|
return this.remoteDesktopFloor.claimHost(ptyId, cols, rows)
|
|
}
|
|
|
|
noteRemoteDesktopHostInput(ptyId: string, inputKind: TerminalInputKind, data: string): void {
|
|
if (inputKind === 'driving' && !isUntypedTerminalInput(data)) {
|
|
this.remoteDesktopFloor.noteHostInput(ptyId)
|
|
}
|
|
}
|
|
|
|
unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise<boolean> {
|
|
return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey])
|
|
}
|
|
|
|
unregisterRemoteDesktopViewers(
|
|
ptyId: string,
|
|
subscriptionKeys: Iterable<string>
|
|
): Promise<boolean> {
|
|
return this.remoteDesktopFloor.unregisterViewers(ptyId, subscriptionKeys)
|
|
}
|
|
|
|
// Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so
|
|
// it must never *create* a width floor (that floor would leak — nothing
|
|
// releases it, pinning the host at a stale width after the viewer is gone).
|
|
// It only refreshes the floor(s) this client already owns via its stream
|
|
// subscription, keyed by clientId. Mirrors the mobile `updateMobileViewport`
|
|
// no-op-without-subscription invariant. Returns false when the client owns no
|
|
// floor (passive/stream-less viewer) — a stream-less viewer must not lock host
|
|
// resize.
|
|
refreshRemoteDesktopViewer(
|
|
ptyId: string,
|
|
clientId: string,
|
|
cols: number,
|
|
rows: number,
|
|
claim = false
|
|
): Promise<boolean> {
|
|
return this.remoteDesktopFloor.refreshViewer(ptyId, clientId, cols, rows, claim)
|
|
}
|
|
|
|
async updateDesktopViewport(
|
|
ptyId: string,
|
|
viewport: { cols: number; rows: number }
|
|
): Promise<boolean> {
|
|
const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows)
|
|
if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') {
|
|
this.recordRendererGeometry(ptyId, cols, rows)
|
|
return true
|
|
}
|
|
if (this.isResizeSuppressed()) {
|
|
return false
|
|
}
|
|
this.freshSubscribeGuard.add(ptyId)
|
|
try {
|
|
const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
|
|
if (result.ok) {
|
|
this.refreshRendererGeometry(ptyId, cols, rows)
|
|
}
|
|
return result.ok
|
|
} finally {
|
|
this.freshSubscribeGuard.delete(ptyId)
|
|
}
|
|
}
|
|
|
|
markMobileActor(ptyId: string, clientId: string): void {
|
|
const inner = this.mobileSubscribers.get(ptyId)
|
|
const sub = inner?.get(clientId)
|
|
if (sub) {
|
|
sub.lastActedAt = Date.now()
|
|
}
|
|
this.setDriver(ptyId, { kind: 'mobile', clientId })
|
|
}
|
|
|
|
beginMobileInputFloor(
|
|
ptyId: string,
|
|
clientId: string
|
|
): { commit: () => Promise<void>; rollback: () => void } | null {
|
|
return this.terminalDrivers.beginMobileInputFloor(ptyId, clientId)
|
|
}
|
|
}
|