diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index b02185a2c26..1393a6b2658 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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) diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index cd21de423ea..8b9a46cc4b8 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -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', diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index 198d5cd6ab2..f43db391edf 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -72,6 +72,8 @@ export type RuntimeConstructionDependencies = { prepareCodexCatalogProbeHome?: (homePath: string) => void buildAgentHookPtyEnv?: () => Record 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 applySessionSearchSettings?: SessionSearchSettingsApply diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index f76db702d96..dd19a0aa2e9 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -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 diff --git a/src/main/runtime/headless-hydration-ownership-test-fixture.ts b/src/main/runtime/headless-hydration-ownership-test-fixture.ts index c514912ee1d..3b069d07b5a 100644 --- a/src/main/runtime/headless-hydration-ownership-test-fixture.ts +++ b/src/main/runtime/headless-hydration-ownership-test-fixture.ts @@ -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[2] +): HydrationRuntime { + const runtime = new HydrationRuntime(store, undefined, deps) syncSinglePty(runtime, PTY_ID) runtimes.push(runtime) return runtime diff --git a/src/main/runtime/headless-terminal-memory-bound.test.ts b/src/main/runtime/headless-terminal-memory-bound.test.ts new file mode 100644 index 00000000000..9b44c36ac60 --- /dev/null +++ b/src/main/runtime/headless-terminal-memory-bound.test.ts @@ -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() + const serializeProviderBuffer = vi + .fn<() => Promise>() + .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() + } +}) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index e1cd456abed..8009e6f7088 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -175,6 +175,11 @@ export type RuntimeVisibleTerminalState = { generation: number } +export type ProviderTerminalBufferWait = { + snapshot: PtyProviderBufferSnapshot | null + late: Promise | null +} + export type ProviderBufferAcquisition = { generation: number scrollbackRows: number diff --git a/src/main/runtime/terminal/output/headless-terminal-hydration.ts b/src/main/runtime/terminal/output/headless-terminal-hydration.ts index 656661fcfdf..2e4f8aa1d50 100644 --- a/src/main/runtime/terminal/output/headless-terminal-hydration.ts +++ b/src/main/runtime/terminal/output/headless-terminal-hydration.ts @@ -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 || diff --git a/src/main/runtime/terminal/output/headless-terminal-state.ts b/src/main/runtime/terminal/output/headless-terminal-state.ts index 4fda67e961b..b23570f56f7 100644 --- a/src/main/runtime/terminal/output/headless-terminal-state.ts +++ b/src/main/runtime/terminal/output/headless-terminal-state.ts @@ -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) } }) diff --git a/src/main/runtime/terminal/output/terminal-buffer-operations.ts b/src/main/runtime/terminal/output/terminal-buffer-operations.ts index d2435725ff0..fc7764cd4fd 100644 --- a/src/main/runtime/terminal/output/terminal-buffer-operations.ts +++ b/src/main/runtime/terminal/output/terminal-buffer-operations.ts @@ -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 + protected abstract awaitProviderTerminalBuffer( + ptyId: string, + opts?: { scrollbackRows?: number }, + wait?: { timeoutMs?: number; retireOnTimeout?: boolean } + ): Promise abstract resolveLeafForHandle(handle: string): { ptyId: string | null } | null abstract clearHeadlessTerminalBuffer(ptyId: string): Promise abstract resetHeadlessTerminalInputModes(ptyId: string): Promise diff --git a/src/main/runtime/terminal/output/terminal-buffer-selection.ts b/src/main/runtime/terminal/output/terminal-buffer-selection.ts index c7d07293042..ef78c76d256 100644 --- a/src/main/runtime/terminal/output/terminal-buffer-selection.ts +++ b/src/main/runtime/terminal/output/terminal-buffer-selection.ts @@ -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 { + 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 { 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 } } }