mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
* fix(pty): key buffered pre-attach exits on the PTY incarnation, not a clock A restarted SSH relay renumbers PTYs from pty-1, so a fresh spawn is routinely handed an id whose previous shell is still emitting a late exit. #16970 stopped that exit blanking the new tab by dating every buffered record and dropping anything older than the spawn request. That fence is a clock, so it cannot judge a stale exit that arrives AFTER the request left — the residual risk #16970 documented. Thread the incarnation main already puts on the pty:exit payload (and the pty:spawn reply) through preload to the pre-handler buffer, so a buffered exit names which lifetime of the id died. An exit disagreeing with the incarnation now attaching is discarded whenever it arrived. Only a positive disagreement discards: absence stays "unknown", never a mismatch, so hosts that predate the field keep #16970's behaviour exactly. The fence is retained for the two cases with no incarnation to compare — buffered bytes (pty:data carries none) and unnamed exits. No wire change: incarnationId was already published on the relay's pty.exit notification and pty.spawn reply, and already forwarded over the in-process pty:exit / pty:spawn IPC. Only the preload types and the renderer read it now. * fix(pty): read the incarnation through the shared guard, not truthiness A malformed incarnation is evidence of nothing, so it must read as "unknown" rather than as a value that disagrees with every well-formed one — otherwise a non-string on the payload would discard the very exits the buffer exists to deliver. Route both the record and the comparison through the existing isPtyIncarnationId guard. * refactor(pty): name the bounded-map helper after what it does It evicts the oldest entry when the map is full and the id is new; it reserves nothing. Rename only — no behaviour change. * fix(pty): key the buffered-exit STORAGE on the incarnation too, not just the check Review caught a swallowed exit. Keying only the comparison on the incarnation while the storage stayed one slot per pty id left the two races this buffer exists for able to cancel each other out: 1. the freshly spawned shell dies before the pane attaches -> its exit (X) is buffered; 2. the relay flushes the previous owner's exit for the same recycled id (W), which OVERWRITES X in the single slot; 3. the spawn reply names X, so the identity discard drops W -- the only record left. registerExit then finds nothing and the pane binds to a PTY that is dead and will never be reported dead: a hang instead of the blank tab #16970 fixed. Store one record per lifetime, capped at 4 per id, so W can never evict X. A duplicate exit for a lifetime replaces that lifetime's record rather than crowding out another's; drain still delivers the newest survivor, preserving the last-write-wins behaviour a single slot always had. * fix(pty): filter buffered exits by lifetime inside the buffer, not at call sites Review found the identity was enforced only where connectIpcPty calls the discard, while preHandlerPtyExit has several other consumers. The severe one is registerEagerPtyBuffer: both background launchers spawn directly and then drain whatever is buffered for the returned id, so a relay-recycled id holding the previous owner's exit tore a freshly launched agent session down seconds after it started -- no fence, no admitPtyId, no identity check at all. Move the rule into the buffer: every read goes through admissiblePreHandlerPtyExits, so a record proven to belong to another lifetime is unreachable by construction rather than because each caller remembered to discard first. hasPreHandlerPtyExit/drainPreHandlerPtyExit take the asking lifetime; registerEagerPtyBuffer and registerExit thread it through, and both background launchers pass the incarnation their own spawn returned. A reader that cannot name an incarnation still sees everything, which is the honest answer -- it holds no evidence to discriminate with. That keeps the pre-spawn fast path in connectIpcPty behaving exactly as it does today; see the PR for the consumers this still does not cover. * chore: drop unrelated formatter churn in reliability-gates.jsonc Repo-wide oxfmt reindented pre-existing entries in a file this change never touches. Keep the diff to the PTY incarnation work.
235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
import type {
|
|
AgentProviderSessionMetadata,
|
|
SleepingAgentLaunchConfig
|
|
} from '../../shared/agent-session-resume'
|
|
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
|
|
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
|
import type { PtyListedSession } from '../../shared/pty-listed-session'
|
|
import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics'
|
|
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
|
|
import type {
|
|
PtyRendererDeliveryHealthReply,
|
|
PtyRendererDeliveryStateReport
|
|
} from '../../shared/pty-renderer-delivery-health'
|
|
import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events'
|
|
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
|
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
|
import type { TuiAgent } from '../../shared/tui-agent'
|
|
import type { PtyManagementApi } from './pty-management-api'
|
|
|
|
export type PtyApi = {
|
|
spawn: (opts: {
|
|
cols: number
|
|
rows: number
|
|
cwd?: string
|
|
cwdFallback?: 'worktree'
|
|
env?: Record<string, string>
|
|
envToDelete?: string[]
|
|
command?: string
|
|
commandDelivery?: 'renderer' | 'provider'
|
|
launchConfig?: SleepingAgentLaunchConfig
|
|
resumeProviderSession?: AgentProviderSessionMetadata
|
|
launchToken?: string
|
|
launchAgent?: TuiAgent
|
|
startupCommandDelivery?: StartupCommandDelivery
|
|
connectionId?: string | null
|
|
worktreeId?: string
|
|
sessionId?: string
|
|
// Why: lets a single tab open in a different shell than the user's default.
|
|
shellOverride?: string
|
|
projectRuntime?: ProjectExecutionRuntimeResolution
|
|
terminalColorQueryReplies?: { foreground?: string; background?: string }
|
|
// Why: mark the PTY hidden before its first byte so the delivery gate owns spawn-time queries (terminal-query-authority.md §races).
|
|
initiallyHidden?: boolean
|
|
// Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md).
|
|
tabId?: string
|
|
leafId?: string
|
|
// Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics).
|
|
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
|
}) => Promise<{
|
|
id: string
|
|
/** Which lifetime of `id` this reply named; absent when the execution host predates the field. */
|
|
incarnationId?: string
|
|
launchAgent?: TuiAgent
|
|
launchConfig?: SleepingAgentLaunchConfig
|
|
snapshot?: string
|
|
snapshotCols?: number
|
|
snapshotRows?: number
|
|
snapshotPrefixAnsi?: string
|
|
snapshotFrameAnsi?: string
|
|
snapshotFrameRestoreAnsi?: string
|
|
snapshotKittyKeyboardFlags?: number
|
|
snapshotTerminalOwner?: 'shell'
|
|
snapshotSeq?: number
|
|
isReattach?: boolean
|
|
isAlternateScreen?: boolean
|
|
replay?: string
|
|
sessionExpired?: boolean
|
|
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
|
|
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
|
agentResumeUnavailable?: true
|
|
}>
|
|
write: (id: string, data: string) => void
|
|
writeAccepted: (id: string, data: string) => Promise<boolean>
|
|
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
|
|
resize: (id: string, cols: number, rows: number) => void
|
|
claimViewport: (id: string, cols: number, rows: number) => void
|
|
reportGeometry: (id: string, cols: number, rows: number) => void
|
|
signal: (id: string, signal: string) => void
|
|
clearBuffer: (id: string) => void
|
|
kill: (id: string, opts?: { keepHistory?: boolean }) => Promise<void>
|
|
ackColdRestore: (id: string) => void
|
|
ackData: (id: string, charCount: number, processedChars?: number) => void
|
|
onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void
|
|
respondDeliveryResync: (payload: {
|
|
requestId: number
|
|
processedCharsByPty: Record<string, number>
|
|
}) => void
|
|
/** Renderer-initiated delivery health/heal lane over invoke — reaches main
|
|
* even when every main→renderer push channel is dead (field wedge). */
|
|
reportRendererDeliveryState: (
|
|
report: PtyRendererDeliveryStateReport
|
|
) => Promise<PtyRendererDeliveryHealthReply>
|
|
/** Live pty:data listener count on the preload emitter (sync) — heal-time
|
|
* discriminator between a detached listener and a dead channel. */
|
|
getPtyDataListenerCount: () => number
|
|
/** One-shot signal that this page's pty:data dispatcher is registered, so
|
|
* main can release sends held during the load/reload boot window. */
|
|
rendererDispatcherReady: () => void
|
|
setActiveRendererPty: (id: string, active: boolean) => void
|
|
setRendererPtyVisible: (id: string, visible: boolean) => void
|
|
/** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer
|
|
* byte delivery after model ingestion; reveal restores from snapshots. */
|
|
setHiddenRendererPty: (id: string, hidden: boolean) => void
|
|
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses
|
|
* the hidden-delivery gate while any raw-byte consumer is registered. */
|
|
setPtyDeliveryInterest: (id: string, interested: boolean) => void
|
|
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
|
|
* appearance push backing main's hidden-PTY OSC/DSR color replies. */
|
|
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
|
|
hasChildProcesses: (id: string) => Promise<boolean>
|
|
getForegroundProcess: (id: string) => Promise<string | null>
|
|
inspectProcess: (id: string) => Promise<{
|
|
foregroundProcess: string | null
|
|
hasChildProcesses: boolean
|
|
unavailable?: true
|
|
}>
|
|
confirmForegroundProcess: (id: string) => Promise<string | null>
|
|
getCwd: (id: string) => Promise<string>
|
|
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
|
|
listSessions: () => Promise<PtyListedSession[]>
|
|
getAuthoritativeBufferSnapshotCapabilities?: (
|
|
ids: string[]
|
|
) => Promise<{ id: string; authoritative: boolean | null }[]>
|
|
hasPty: (id: string) => Promise<boolean | null>
|
|
getMainBufferSnapshot: (
|
|
id: string,
|
|
opts?: { scrollbackRows?: number }
|
|
) => Promise<{
|
|
data: string
|
|
frameRestoreAnsi?: string
|
|
cols: number
|
|
rows: number
|
|
cwd?: string | null
|
|
seq?: number
|
|
/** Start of main's pending renderer-delivery queue at snapshot time
|
|
* (equals `seq` when empty) — bounds the renderer's post-restore
|
|
* duplicate window. */
|
|
pendingDeliveryStartSeq?: number
|
|
source?: 'headless' | 'renderer'
|
|
alternateScreen?: boolean
|
|
/** Authoritative normal buffer paired with an alternate-screen frame. */
|
|
scrollbackAnsi?: string
|
|
/** Trailing incomplete escape the emulator ingested; the restorer must
|
|
* write it after its post-replay resets, last before live chunks. */
|
|
pendingEscapeTailAnsi?: string
|
|
/** Effective kitty flags the snapshot owner proved at `seq`. Absent means
|
|
* unknown; consumers must not turn that into a known `0`. */
|
|
kittyKeyboardFlags?: number
|
|
terminalOwner?: 'shell'
|
|
} | null>
|
|
getRendererDeliveryDebugSnapshot: () => Promise<{
|
|
pendingPtyCount: number
|
|
pendingChars: number
|
|
maxPendingCharsByPty: number
|
|
rendererInFlightPtyCount: number
|
|
rendererInFlightChars: number
|
|
maxRendererInFlightCharsByPty: number
|
|
activeRendererPtyCount: number
|
|
flushScheduled: boolean
|
|
peakPendingChars: number
|
|
peakMaxPendingCharsByPty: number
|
|
peakRendererInFlightChars: number
|
|
peakMaxRendererInFlightCharsByPty: number
|
|
ackGatedFlushSkipCount: number
|
|
hiddenDeliveryGatedPtyCount: number
|
|
hiddenDeliveryGatedVisiblePtyCount: number
|
|
hiddenDeliveryGatedActivePtyCount: number
|
|
deliveryInterestPtyCount: number
|
|
hiddenDeliveryDroppedChars: number
|
|
hiddenDeliveryDroppedChunks: number
|
|
pendingDroppedChars: number
|
|
diagnostics: PtyMainDeliveryDiagnostics
|
|
rendererLifecycleResetCount: number
|
|
lastLifecycleResetClearedChars: number
|
|
rendererPtyDispatcherReady: boolean
|
|
rendererDispatcherReadyForcedCount: number
|
|
}>
|
|
resetRendererDeliveryDebug: () => Promise<void>
|
|
onData: (
|
|
callback: (data: {
|
|
id: string
|
|
data: string
|
|
seq?: number
|
|
rawLength?: number
|
|
transformed?: boolean
|
|
background?: boolean
|
|
droppedOutput?: boolean
|
|
}) => void
|
|
) => () => void
|
|
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
|
|
/** Out-of-band main→renderer signal that renderer-bound bytes were
|
|
* dropped (hidden-delivery gate / pending cap); the pane restores from
|
|
* the model snapshot. Never delivered in-band on pty:data. */
|
|
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void
|
|
/** Batched derived side-effect facts for PTYs whose bytes transit local
|
|
* main. */
|
|
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
|
|
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
|
|
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
|
|
onExit: (
|
|
callback: (data: {
|
|
id: string
|
|
code: number
|
|
preserveRendererBinding?: boolean
|
|
/** Which lifetime of `id` died; absent when the execution host predates the field. */
|
|
incarnationId?: string
|
|
}) => void
|
|
) => () => void
|
|
onSpawned: (callback: (data: { id: string }) => void) => () => void
|
|
onSerializeBufferRequest: (
|
|
callback: (data: {
|
|
requestId: string
|
|
ptyId: string
|
|
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
|
}) => void
|
|
) => () => void
|
|
onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void
|
|
sendSerializedBuffer: (
|
|
requestId: string,
|
|
snapshot: {
|
|
data: string
|
|
cols: number
|
|
rows: number
|
|
seq?: number
|
|
lastTitle?: string
|
|
kittyKeyboardFlags?: number
|
|
} | null
|
|
) => void
|
|
declarePendingPaneSerializer: (paneKey: string) => Promise<number>
|
|
settlePaneSerializer: (paneKey: string, gen: number) => Promise<void>
|
|
clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise<void>
|
|
reportRendererSerializerReady?: (ptyId: string) => Promise<void>
|
|
management: PtyManagementApi
|
|
}
|