fix(orcad): keep terminal models at the daemon's live depth and bound provider snapshot waits (#35679)

* fix(orcad): keep orcad's terminal models at the daemon's live depth

orcad's per-PTY headless terminal model kept 5,000 rows of scrollback, but
orcad has no renderer to reveal from: requested snapshots read the daemon
first and subscribe snapshots read at most 1,000 rows. The model now
mirrors the daemon's live window, from the same resolver the daemon uses.

The execution-context reseed also awaited its provider snapshot with no
bound, so a provider that never answered held every live chunk queued
behind it. It now uses the authoritative snapshot timeout.

Refs #34410
Refs #33870

* fix(orcad): keep provider preference when the reseed snapshot times out

Refs #34410
Refs #33870

* fix(runtime): bound every provider snapshot wait in one place

serializeProviderTerminalBuffer now applies the authoritative snapshot
timeout when a caller passes none, so no reader can wait on a provider that
never answers. The execution-context reseed relies on that bound; when its
capture times out it keeps the PTY provider-preferred and reseeds again once
the capture lands, instead of staying on the provider path.

Refs #34410
Refs #33870

* fix(runtime): reseed once after a late provider snapshot, trust the model on none

awaitProviderTerminalBuffer reports the capture still running when the
bounded wait gives up, instead of callers inferring it from the acquisition
map. The execution-context reseed retries once when that capture lands with
a snapshot, and stops preferring the provider when it lands with none, so a
slow provider can neither loop reseeds nor pin the PTY to the provider path.

Refs #34410
Refs #33870

* fix(runtime): never trust a late-reseed model that found no snapshot

The once-only retry disposes a model that had parsed the live bytes since
the timeout. If its own capture then fails, the replacement holds less than
its predecessor, so the PTY keeps preferring the provider instead of
treating that model as complete.

Refs #34410
Refs #33870
This commit is contained in:
Neil
2026-10-28 22:14:33 -07:00
committed by GitHub
parent 938f9ff80f
commit e22e9769b9
11 changed files with 200 additions and 17 deletions
+4
View File
@@ -90,6 +90,8 @@ async function startOrcadRuntime(
const { buildOrcadServeReadiness } = await import('./orcad-serve-readiness')
const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup')
const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-provider-state')
const { resolveDaemonSessionScrollbackRows } =
await import('../daemon/daemon-session-scrollback-window')
const { collectOrcadHealth } = await import('./orcad-health')
const { agentHookServer } = await import('../agent-hooks/server')
const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls')
@@ -210,6 +212,8 @@ async function startOrcadRuntime(
canRecoverPersistentLocalPtys: () => daemonOwnsFreshPersistentPtys(),
// Node hosts cannot promote into desktop windows; override the constructor’s openable default.
getDesktopWindowStatus: () => 'blocked',
// No renderer reveals from this model; it keeps the daemon's live window, which deeper snapshot reads ask first.
headlessTerminalScrollbackRows: resolveDaemonSessionScrollbackRows(),
// Unchanged settings would reconstruct a healthy search index; deduplicate before apply.
applySessionSearchSettings: (before, after) => {
const next = changedAiVaultSearchSettings(before, after)
@@ -97,6 +97,12 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [
reason:
'Sleep reaches paired servers and suspends VMs from the desktop only; orcad refuses those parts.'
},
{
wiring: 'ctor:headlessTerminalScrollbackRows',
missingOn: 'electron',
reason:
"Desktop reveals hidden panes from its model at desktop depth; renderer-less orcad needs only the daemon's live window."
},
{
wiring: 'rpc-option:exposeNetworkByDefault',
missingOn: 'orcad',
@@ -72,6 +72,8 @@ export type RuntimeConstructionDependencies = {
prepareCodexCatalogProbeHome?: (homePath: string) => void
buildAgentHookPtyEnv?: () => Record<string, string>
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
/** Live headless-model depth; a host with no renderer to reveal from needs only its PTY provider's live window. */
headlessTerminalScrollbackRows?: number
agentSessionClaimSigner?: AgentSessionClaimSigner
skillTransactionRecovery?: Promise<unknown>
applySessionSearchSettings?: SessionSearchSettingsApply
@@ -43,6 +43,7 @@ import { registerConptyDa1OverrideInstaller } from '../terminal-model-query-auth
import { registerTerminalViewAttributesApplier } from '../terminal-view-attribute-store'
import { RuntimeMachineName } from '../runtime-machine-name'
import { setPRConflictSummaryRemoteFetches } from '../../github/conflict-summary'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../shared/terminal-scrollback-policy'
// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The constructor installs every checked canonical surface.
export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
@@ -73,6 +74,7 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
declare protected readonly getPairedDeviceNameFn: HostServices['getPairedDeviceNameFn']
declare protected readonly buildAgentHookPtyEnv: HostServices['buildAgentHookPtyEnv']
declare protected readonly getDesktopWindowStatusFn: HostServices['getDesktopWindowStatusFn']
protected readonly headlessTerminalScrollbackRows: number
declare protected readonly prepareAiVaultSessionResumeFn: HostServices['prepareAiVaultSessionResumeFn']
declare protected readonly prepareCodexStructuredLaunchFn: HostServices['prepareCodexStructuredLaunchFn']
declare protected readonly resolveCodexStructuredLaunchHomeFn: HostServices['resolveCodexStructuredLaunchHomeFn']
@@ -251,6 +253,8 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
this.onClaudeTerminalEvidence = deps?.onClaudeTerminalEvidence ?? null
this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null
this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable')
this.headlessTerminalScrollbackRows =
deps?.headlessTerminalScrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT
this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null
this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null
this.resolveCodexStructuredLaunchHomeFn = deps?.resolveCodexStructuredLaunchHome ?? null
@@ -56,8 +56,10 @@ export class HydrationRuntime extends OrcaRuntimeService {
const runtimes: HydrationRuntime[] = []
export function createHydrationRuntime(): HydrationRuntime {
const runtime = new HydrationRuntime(store)
export function createHydrationRuntime(
deps?: ConstructorParameters<typeof OrcaRuntimeService>[2]
): HydrationRuntime {
const runtime = new HydrationRuntime(store, undefined, deps)
syncSinglePty(runtime, PTY_ID)
runtimes.push(runtime)
return runtime
@@ -0,0 +1,112 @@
import { expect, it, vi } from 'vitest'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from '../daemon/daemon-session-scrollback-window'
import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from './orca-runtime-postlude'
import type { PtyProviderBufferSnapshot } from '../providers/pty-provider-contract'
import { deferred } from './orca-runtime-test-fixtures.spec'
import { createHydrationRuntime, PTY_ID, SIZE } from './headless-hydration-ownership-test-fixture'
it('retains only the configured scrollback depth', async () => {
const runtime = createHydrationRuntime({
headlessTerminalScrollbackRows: DAEMON_SESSION_SCROLLBACK_ROWS
})
const lines = Array.from({ length: 3000 }, (_, i) => `L${String(i + 1).padStart(4, '0')}`)
runtime.onPtyData(PTY_ID, lines.join('\r\n'), 1)
await runtime.model().writeChain
const snapshot = await runtime.serializeMainTerminalBuffer(PTY_ID, { scrollbackRows: 5000 })
const retained = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`.match(/L\d{4}/g)
expect(retained).toHaveLength(DAEMON_SESSION_SCROLLBACK_ROWS + SIZE.rows)
expect(retained?.at(-1)).toBe('L3000')
})
function hangingProvider() {
const runtime = createHydrationRuntime()
const late = deferred<PtyProviderBufferSnapshot | null>()
const serializeProviderBuffer = vi
.fn<() => Promise<PtyProviderBufferSnapshot | null>>()
.mockReturnValueOnce(late.promise)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
getSize: () => SIZE,
serializeProviderBuffer
})
return { runtime, late, serializeProviderBuffer }
}
it('bounds a provider snapshot that does not answer, then reseeds once it lands', async () => {
vi.useFakeTimers()
try {
const { runtime, late, serializeProviderBuffer } = hangingProvider()
runtime.replaceExecutionContext()
const stalled = runtime.model()
runtime.onPtyData(PTY_ID, 'LIVE-AFTER-SEED', 1)
await vi.advanceTimersByTimeAsync(AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS)
await stalled.writeChain
expect(stalled.emulator.getVisibleLines().join('\n')).toContain('LIVE-AFTER-SEED')
// Partial until a seed lands, so readers keep asking the provider.
expect(runtime.retainedState().providerPreferred).toBe(true)
serializeProviderBuffer.mockResolvedValue({
...SIZE,
data: 'FRESH-SEED',
seq: 1,
source: 'headless'
})
late.resolve({ ...SIZE, data: 'LATE-SEED', seq: 0, source: 'headless' })
await vi.waitFor(() => expect(runtime.model()).not.toBe(stalled))
await runtime.model().writeChain
expect(runtime.model().emulator.getVisibleLines().join('\n')).toContain('FRESH-SEED')
expect(runtime.retainedState().providerPreferred).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('keeps preferring the provider when the late retry finds no snapshot', async () => {
vi.useFakeTimers()
try {
const { runtime, late, serializeProviderBuffer } = hangingProvider()
runtime.replaceExecutionContext()
const stalled = runtime.model()
await vi.advanceTimersByTimeAsync(AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS)
serializeProviderBuffer.mockResolvedValue(null)
late.resolve({ ...SIZE, data: 'LATE-SEED', seq: 0, source: 'headless' })
await vi.waitFor(() => expect(runtime.model()).not.toBe(stalled))
await runtime.model().writeChain
// The retry's model lost what its predecessor held, so it must not become authoritative.
expect(runtime.retainedState().providerPreferred).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('trusts the model when a timed-out capture lands with no snapshot', async () => {
vi.useFakeTimers()
try {
const { runtime, late, serializeProviderBuffer } = hangingProvider()
runtime.replaceExecutionContext()
const model = runtime.model()
await vi.advanceTimersByTimeAsync(AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS)
late.resolve(null)
await vi.waitFor(() => expect(runtime.retainedState().providerPreferred).toBe(false))
expect(runtime.model()).toBe(model)
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('bounds every reader of a provider snapshot that does not answer', async () => {
vi.useFakeTimers()
try {
const { runtime } = hangingProvider()
runtime.preferProvider()
const read = runtime.serializeTerminalBuffer(PTY_ID)
// Preferred-provider read, then the no-model fallback: two bounded waits.
await vi.advanceTimersByTimeAsync(2 * AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS)
await expect(read).resolves.toBeNull()
} finally {
vi.useRealTimers()
}
})
@@ -175,6 +175,11 @@ export type RuntimeVisibleTerminalState = {
generation: number
}
export type ProviderTerminalBufferWait = {
snapshot: PtyProviderBufferSnapshot | null
late: Promise<PtyProviderBufferSnapshot | null> | null
}
export type ProviderBufferAcquisition = {
generation: number
scrollbackRows: number
@@ -13,7 +13,6 @@ import {
import { TrustDialogStatusObserver } from '../../trust-dialog-status-observer'
import type { LaunchedAgentForeground } from '../../launched-agent-foreground'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy'
import {
detectAgentStatusFromTitle,
normalizeTerminalTitle
@@ -92,9 +91,9 @@ export abstract class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends Or
}
try {
// Retain normal-buffer scrollback while a TUI is in alt-screen so pre-TUI history remains restorable (#6106).
// Desktop depth (this emulator's retention): a hidden-output reveal repaints the pane from this model.
// This emulator's retention: a hidden-output reveal repaints the pane from this model.
const rendered = await controller.serializeBuffer!(ptyId, {
scrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT
scrollbackRows: this.headlessTerminalScrollbackRows
})
if (
this.headlessTerminals.get(ptyId) !== state ||
@@ -41,6 +41,7 @@ export abstract class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends Orca
const emulator = new HeadlessEmulator({
cols: dims.cols,
rows: dims.rows,
scrollback: this.headlessTerminalScrollbackRows,
pathFlavor,
remotePosixFileUriAuthority:
!!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32',
@@ -112,19 +113,47 @@ export abstract class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends Orca
return state
}
protected replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void {
protected replaceHeadlessTerminalAfterExecutionContextChange(
ptyId: string,
retryLateSeed = true
): void {
this.disposeHeadlessTerminal(ptyId)
this.providerSnapshotPreferredPtys.add(ptyId)
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
this.headlessTerminals.set(ptyId, state)
const generation = this.getPtyLifecycleGeneration(ptyId)
let seedTimedOut = false
let seeded = false
state.writeChain = state.writeChain
.then(async () => {
if (this.headlessTerminals.get(ptyId) !== state) {
return
}
const snapshot = await this.serializeProviderTerminalBuffer(ptyId)
if (this.headlessTerminals.get(ptyId) !== state || !snapshot) {
const { snapshot, late } = await this.awaitProviderTerminalBuffer(ptyId)
if (this.headlessTerminals.get(ptyId) !== state) {
return
}
if (!snapshot) {
seedTimedOut = late !== null
void late?.then((landed) => {
if (
this.headlessTerminals.get(ptyId) !== state ||
this.getPtyLifecycleGeneration(ptyId) !== generation
) {
return
}
if (!retryLateSeed) {
return
}
if (!landed) {
// The provider gave nothing, so trust the live model.
this.providerSnapshotPreferredPtys.delete(ptyId)
} else {
// Live bytes moved this model on, so the late image is stale; capture once more.
this.replaceHeadlessTerminalAfterExecutionContextChange(ptyId, false)
}
})
return
}
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
@@ -150,12 +179,17 @@ export abstract class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends Orca
alternateScreen: state.emulator.isAlternateScreen
})
state.outputSequence = snapshot.seq
seeded = true
})
.catch(() => {
// Best-effort: live bytes already chain behind this replacement state.
})
.finally(() => {
if (this.headlessTerminals.get(ptyId) === state) {
// An unseeded model stays partial, so readers keep preferring the provider; a retry also dropped the bytes its predecessor held.
if (
this.headlessTerminals.get(ptyId) === state &&
(seeded || (retryLateSeed && !seedTimedOut))
) {
this.providerSnapshotPreferredPtys.delete(ptyId)
}
})
@@ -1,6 +1,7 @@
import type { PtyProviderBufferSnapshot } from '../../../providers/pty-provider-contract'
import type {
HeadlessSeedMetadata,
ProviderTerminalBufferWait,
RuntimeHeadlessTerminal
} from '../../runtime-terminal-state-records'
import type { OrcaRuntimeWithSerializeTerminalBufferFromAvailableState } from './terminal-buffer-selection'
@@ -59,6 +60,11 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun
opts?: { scrollbackRows?: number },
wait?: { timeoutMs?: number; retireOnTimeout?: boolean }
): Promise<PtyProviderBufferSnapshot | null>
protected abstract awaitProviderTerminalBuffer(
ptyId: string,
opts?: { scrollbackRows?: number },
wait?: { timeoutMs?: number; retireOnTimeout?: boolean }
): Promise<ProviderTerminalBufferWait>
abstract resolveLeafForHandle(handle: string): { ptyId: string | null } | null
abstract clearHeadlessTerminalBuffer(ptyId: string): Promise<void>
abstract resetHeadlessTerminalInputModes(ptyId: string): Promise<void>
@@ -1,7 +1,9 @@
import { OrcaRuntimeWithCreatePtyHeadlessTerminalState } from './headless-terminal-state'
import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges'
import type { PtyProviderBufferSnapshot } from '../../../providers/pty-provider-contract'
import type { ProviderTerminalBufferWait } from '../../runtime-terminal-state-records'
import { withTimeout } from '../../runtime-async-boundaries'
import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from '../../orca-runtime-postlude'
export abstract class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends OrcaRuntimeWithCreatePtyHeadlessTerminalState {
protected abstract captureProviderTerminalBuffer(
@@ -124,11 +126,20 @@ export abstract class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState e
opts: { scrollbackRows?: number } = {},
wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<PtyProviderBufferSnapshot | null> {
return (await this.awaitProviderTerminalBuffer(ptyId, opts, wait)).snapshot
}
/** `late` is the capture still running when the bounded wait gave up, so a caller can act once it lands. */
protected async awaitProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {},
wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
): Promise<ProviderTerminalBufferWait> {
const generation = this.getPtyLifecycleGeneration(ptyId)
const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0))
let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId)
if (acquisition?.generation === generation && acquisition.timedOut) {
return null
return { snapshot: null, late: acquisition.promise }
}
if (
!acquisition ||
@@ -145,24 +156,22 @@ export abstract class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState e
})
}
if (acquisition.timedOut) {
return null
}
if (typeof wait.timeoutMs !== 'number') {
return acquisition.promise
return { snapshot: null, late: acquisition.promise }
}
// Every reader shares this bound: a provider that never answers must not hold its caller.
const result = await withTimeout<
{ settled: true; value: PtyProviderBufferSnapshot | null } | { settled: false }
>(
acquisition.promise.then((value) => ({ settled: true as const, value })),
wait.timeoutMs,
wait.timeoutMs ?? AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
{ settled: false as const }
)
if (!result.settled) {
if (wait.retireOnTimeout) {
acquisition.timedOut = true
}
return null
return { snapshot: null, late: acquisition.promise }
}
return result.value
return { snapshot: result.value, late: null }
}
}