From e22e9769b923e8bee86ecbdab97d7b44b3285d29 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:14:33 -0700 Subject: [PATCH] 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 --- src/main/orcad/orcad-entry.ts | 4 + .../runtime-composition-parity.test.ts | 6 + .../runtime-construction-dependencies.ts | 2 + .../construction/runtime-construction.ts | 4 + ...adless-hydration-ownership-test-fixture.ts | 6 +- .../headless-terminal-memory-bound.test.ts | 112 ++++++++++++++++++ .../runtime/runtime-terminal-state-records.ts | 5 + .../output/headless-terminal-hydration.ts | 5 +- .../output/headless-terminal-state.ts | 42 ++++++- .../output/terminal-buffer-operations.ts | 6 + .../output/terminal-buffer-selection.ts | 25 ++-- 11 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 src/main/runtime/headless-terminal-memory-bound.test.ts 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 } } }