diff --git a/src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts b/src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts index acf4f60532a..09c97160534 100644 --- a/src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts +++ b/src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts @@ -89,6 +89,7 @@ export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWit return read } const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts) + const forceVisibleCapture = providerSnapshot.freshVisibleCapture === true const recoveredWorkerFallback = read.tail.length === 0 && this.legacyWorkerRecovery.hasRecoveredPty(ptyId) // Why: a live daemon session no pane ever attached has ingested zero bytes, @@ -116,6 +117,7 @@ export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWit const providerModeUnknown = this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) if ( + !forceVisibleCapture && !blankFallback && !recoveredWorkerFallback && !providerModeUnknown && @@ -124,8 +126,11 @@ export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWit ) { return read } - const visibleState = await this.readVisibleTerminalState(ptyId) + const visibleState = await this.readVisibleTerminalState(ptyId, { + freshCapture: providerSnapshot.freshVisibleCapture + }) if ( + !forceVisibleCapture && !blankFallback && !recoveredWorkerFallback && !knownAlternateScreen && @@ -149,6 +154,7 @@ export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWit snapshotOptions: RuntimeProviderSnapshotReadOptions = {} ): Promise { const generation = this.getPtyLifecycleGeneration(ptyId) + const attachmentId = this.getPtyAttachmentId(ptyId) const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const snapshot = await this.serializeProviderTerminalBuffer( ptyId, @@ -163,10 +169,17 @@ export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWit if (snapshotOptions.visibleScreenOnly) { const projection = await this.parseVisibleSnapshot(snapshot) // Live bytes ordered after the provider frame make that frame stale. - return this.getPtyLifecycleGeneration(ptyId) === generation && - this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? projection - : { lines: [] } + if ( + this.getPtyLifecycleGeneration(ptyId) !== generation || + this.getPtyOutputSequence(ptyId) > snapshot.seq || + this.getPtyAttachmentId(ptyId) !== attachmentId + ) { + return { lines: [] } + } + return { + ...projection, + screenCapture: this.recordVisibleScreenCapture(ptyId, generation, snapshot.seq, 'provider') + } } const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` if (data.length === 0) { diff --git a/src/main/runtime/orca-runtime-core.ts b/src/main/runtime/orca-runtime-core.ts index 8214f750676..8fbe6a08e42 100644 --- a/src/main/runtime/orca-runtime-core.ts +++ b/src/main/runtime/orca-runtime-core.ts @@ -97,7 +97,25 @@ export const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000 export const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000 -export type RuntimeTerminalProjection = { lines: string[]; draft?: string } +/** Host-owned provenance for one rendered terminal-screen capture. + * + * The revision advances only when a provider/renderer/emulator actually captures a + * frame. Reusing a cache entry preserves its revision; consumers must never mint a + * wall-clock timestamp while projecting that entry. + */ +export type RuntimeScreenCapture = { + attachmentId: string + generation: number + outputSequence: number + revision: number + source: 'provider' | 'renderer' | 'headless' +} + +export type RuntimeTerminalProjection = { + lines: string[] + draft?: string + screenCapture?: RuntimeScreenCapture +} export function assertAgentPromptRequestActive(signal?: AbortSignal): void { if (signal?.aborted) { diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 5ef17ce4c2e..4b40aaacb69 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -9,6 +9,7 @@ import type { RuntimePtyWorktreeRecord, RuntimeVisibleTerminalState } from './runtime-terminal-state-records' +import type { RuntimeScreenCapture } from './orca-runtime-core' import type { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' import type { PtyProviderBufferSnapshot } from '../providers/types' import type { WaitBlockedCheckState } from './wait-blocked-check-state' @@ -66,11 +67,21 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected providerVisibleStateReadsByPtyId = new Map< string, - { generation: number; promise: Promise } + { + generation: number + freshCapture: boolean + promise: Promise + } >() protected providerVisibleRetryAtByPtyId = new Map() + /** Latest host-owned screen provenance observed for each PTY. This is a bridge + * for wait evaluation; the projection itself carries the same provenance. */ + protected visibleScreenCaptureByPtyId = new Map() + + protected nextVisibleScreenCaptureRevision = 1 + protected providerSnapshotsWithLiveModeTransition = new WeakSet() protected ptyLifecycleGenerationById = new Map() diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index a87f6cc03f2..136b8ce8062 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -136,8 +136,11 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd if (incarnationId) { return `${record.ptyId}:${incarnationId}` } - // Why: legacy providers may omit process incarnation; retain the prior restart-degraded fence. - return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` + // Legacy providers may omit process incarnation. Reuse the host-owned + // lifecycle attachment identity used by readiness evidence so polls, + // title resolution, timeout settlement, and screen probes fence the same + // replacement boundary. + return this.getPtyAttachmentId(record.ptyId) } getExactWorkerProviderSession( diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 1f55a7d2404..eccf886bb93 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -225,7 +225,8 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution ptyRecord.lastExplicitAgentStatus = { state: latestAgentStatus.state, updatedAt: at, - ...(ptyRecord.incarnationId ? { attachmentId: ptyRecord.incarnationId } : {}) + outputSequence, + attachmentId: this.getPtyAttachmentId(ptyId) } } } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 6ddf877f87c..52bec4938c0 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -138,6 +138,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.providerModeSnapshotScansByPtyId.delete(ptyId) this.providerBufferAcquisitionsByPtyId.delete(ptyId) this.providerVisibleStateByPtyId.delete(ptyId) + this.visibleScreenCaptureByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) // Safe against respawn: `getPtyLifecycleGeneration` lazily mints from the diff --git a/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts b/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts index 999f23d6ad3..26c9b148f95 100644 --- a/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts +++ b/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts @@ -70,6 +70,19 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW return this.ptyOutputSequenceById.get(ptyId) ?? 0 } + /** + * Resolve one host-owned attachment identity for evidence that has no provider + * incarnation. The lifecycle generation is retired on every exit/replacement, + * so a legacy/null-incarnation PTY is never treated as an anonymous wildcard. + */ + protected getPtyAttachmentId(ptyId: string): string { + const incarnationId = this.ptysById.get(ptyId)?.incarnationId + if (incarnationId) { + return `${ptyId}:${incarnationId}` + } + return `${this.runtimeId}:${ptyId}:generation:${this.getPtyLifecycleGeneration(ptyId)}` + } + protected getPtyLifecycleGeneration(ptyId: string): number { const existing = this.ptyLifecycleGenerationById.get(ptyId) if (existing !== undefined) { @@ -89,6 +102,10 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW this.agentPromptLifecycleByPtyId.delete(ptyId) this.agentPromptPermissionSequenceByPtyId.delete(ptyId) this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now()) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.lastExplicitAgentStatus = null + } this.legacyWorkerRecovery.deleteRecoveredPty(ptyId) // Why: a respawn under the same session id needs its own subscriber-driven attach. this.terminalViewSubscribers.resetGeneration(ptyId) @@ -96,6 +113,7 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW // it; a respawn must neither reuse its frame nor join its in-flight call. this.providerBufferAcquisitionsByPtyId.delete(ptyId) this.providerVisibleStateByPtyId.delete(ptyId) + this.visibleScreenCaptureByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) } diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index 5cb6252f368..8410d0b3c44 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -56,6 +56,14 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc // provider-supported readiness fact; name-only or otherwise unbound observations stay open. for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { + if ( + waiter.processIncarnation === null || + this.getTerminalProcessIncarnation(handle) !== waiter.processIncarnation + ) { + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_handle_stale')) + continue + } const observation = this.observeTuiIdleForLeaf(leaf, waiter.evidenceCursor) if (observation.state !== 'ready') { continue @@ -103,6 +111,14 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc // Why: same re-ranking as resolveTuiIdleWaiters above. for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { + if ( + waiter.processIncarnation === null || + this.getTerminalProcessIncarnation(handle) !== waiter.processIncarnation + ) { + this.removeWaiter(waiter) + waiter.reject(new Error('terminal_handle_stale')) + continue + } const observation = this.observeTuiIdleForPty(pty, waiter.evidenceCursor) if (observation.state !== 'ready') { continue @@ -133,7 +149,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc return observeTuiIdle({ record: { ...leaf, - attachmentId: leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null + attachmentId: leaf.ptyId ? this.getPtyAttachmentId(leaf.ptyId) : null, + screenCapture: leaf.ptyId + ? (this.visibleScreenCaptureByPtyId.get(leaf.ptyId) ?? null) + : null }, rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null, readPositiveBodyEvidence: () => promptAgent !== null, @@ -181,7 +200,8 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc record: { ...pty, lastOscTitleObservedAt: pty.lastOscTitleEpochMs, - attachmentId: pty.incarnationId + attachmentId: this.getPtyAttachmentId(pty.ptyId), + screenCapture: this.visibleScreenCaptureByPtyId.get(pty.ptyId) ?? null }, rendererTitle: adoptedTitle, readPositiveBodyEvidence: () => adoptedIdle || promptAgent !== null, diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 1b9ca39407f..12a2eca3c76 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -324,10 +324,18 @@ export class OrcaRuntimeWithRuntimeId { getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty), getAdoptedPtyTitle: (pty) => this.getAdoptedPtyTitle(pty), getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), - getFirstPartyAgentStatus: (ptyId) => - (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, - getAttachmentId: (ptyId) => (ptyId ? (this.ptysById.get(ptyId)?.incarnationId ?? null) : null), + getFirstPartyAgentStatus: (ptyId) => { + const status = ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null + return status ?? null + }, + getAttachmentId: (ptyId) => (ptyId ? this.getPtyAttachmentId(ptyId) : null), + getScreenCapture: (ptyId) => + ptyId ? (this.visibleScreenCaptureByPtyId.get(ptyId) ?? null) : null, getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle), + retire: (waiter, reason) => { + this.terminalWaiters.remove(waiter) + waiter.reject(new Error(reason)) + }, getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf, resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result) }) @@ -341,10 +349,13 @@ export class OrcaRuntimeWithRuntimeId { getAdoptedPtyTitle: (pty) => this.getAdoptedPtyTitle(pty), getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), - getFirstPartyAgentStatus: (ptyId) => - (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, - getAttachmentId: (ptyId) => - ptyId ? (this.ptysById.get(ptyId)?.incarnationId ?? null) : null, + getFirstPartyAgentStatus: (ptyId) => { + const status = ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null + return status ?? null + }, + getAttachmentId: (ptyId) => (ptyId ? this.getPtyAttachmentId(ptyId) : null), + getScreenCapture: (ptyId) => + ptyId ? (this.visibleScreenCaptureByPtyId.get(ptyId) ?? null) : null, getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle), startVisibleReadProbe: (waiter, waiterTimeoutMs) => this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index 264e178170e..a1f2e2e0b95 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -26,6 +26,7 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { createSetupCompletionScanner } from './orchestration/setup-completion-signal' +import type { RuntimeScreenCapture } from './orca-runtime-core' export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWithCreateAgentPromptRenderGate { /** One bounded look at the provider's screen for an adopted PTY whose retained @@ -55,7 +56,8 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith retireOnTimeout: true, // Why: the ready banner stays in scrollback for the whole session, so // classifying history would call a working agent idle (#15569 review). - visibleScreenOnly: true + visibleScreenOnly: true, + freshVisibleCapture: true } satisfies RuntimeProviderSnapshotReadOptions), probeTimeoutMs, null @@ -73,11 +75,15 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith const snapshotText = projection.tail.join('\n') const blockedReason = detectTerminalWaitBlockedReason(snapshotText) const promptAgent = detectKnownReadyPromptAgent(snapshotText) + const screenCapture = this.visibleScreenCaptureByPtyId.get( + this.getLivePtyForHandle(waiter.handle)?.pty.ptyId ?? '' + ) const result = this.buildTuiIdleProbeResult( waiter.handle, blockedReason, promptAgent, - waiter.evidenceCursor + waiter.evidenceCursor, + screenCapture ?? null ) if (!result) { return @@ -94,7 +100,8 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith handle: string, blockedReason: RuntimeTerminalWaitBlockedReason | null, promptAgent: KnownReadyPromptAgent | null, - evidenceCursor?: TuiIdleEvidenceCursor + evidenceCursor?: TuiIdleEvidenceCursor, + screenCapture?: RuntimeScreenCapture | null ): RuntimeTerminalWait | null { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -105,8 +112,8 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith record: { ...pty.pty, lastOscTitleObservedAt: pty.pty.lastOscTitleEpochMs, - attachmentId: pty.pty.incarnationId, - screenObservedAt: Date.now() + attachmentId: this.getPtyAttachmentId(pty.pty.ptyId), + screenCapture: screenCapture ?? null }, rendererTitle: this.getAdoptedPtyTitle(pty.pty), readPositiveBodyEvidence: () => promptAgent !== null, @@ -131,8 +138,8 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith const observation = observeTuiIdle({ record: { ...leaf, - attachmentId: leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null, - screenObservedAt: Date.now() + attachmentId: leaf.ptyId ? this.getPtyAttachmentId(leaf.ptyId) : null, + screenCapture: screenCapture ?? null }, rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null, readPositiveBodyEvidence: () => promptAgent !== null, diff --git a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts index ac9eb99a67b..d011d1eb631 100644 --- a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts +++ b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithCaptureProviderTerminalBuffer } from './orca-runtime-capture-provider-terminal-buffer' -import type { RuntimeTerminalProjection } from './orca-runtime-core' +import type { RuntimeScreenCapture, RuntimeTerminalProjection } from './orca-runtime-core' import { buildPreview } from './terminal-tail-state' import type { RuntimeVisibleTerminalState } from './runtime-terminal-state-records' import { @@ -31,26 +31,33 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur } protected async readVisibleTerminalState( - ptyId: string + ptyId: string, + options: { freshCapture?: boolean } = {} ): Promise { const generation = this.getPtyLifecycleGeneration(ptyId) const pending = this.providerVisibleStateReadsByPtyId.get(ptyId) - if (pending?.generation === generation) { + const freshCapture = options.freshCapture === true + if (pending?.generation === generation && (!freshCapture || pending.freshCapture)) { return pending.promise } - let entry: { generation: number; promise: Promise } - const promise = this.loadVisibleTerminalState(ptyId).finally(() => { + let entry: { + generation: number + freshCapture: boolean + promise: Promise + } + const promise = this.loadVisibleTerminalState(ptyId, options).finally(() => { if (this.providerVisibleStateReadsByPtyId.get(ptyId) === entry) { this.providerVisibleStateReadsByPtyId.delete(ptyId) } }) - entry = { generation, promise } + entry = { generation, freshCapture, promise } this.providerVisibleStateReadsByPtyId.set(ptyId, entry) return promise } protected async loadVisibleTerminalState( - ptyId: string + ptyId: string, + options: { freshCapture?: boolean } = {} ): Promise { if (!this.providerSnapshotPreferredPtys.has(ptyId)) { return this.readHeadlessVisibleTerminalState(ptyId) @@ -61,6 +68,7 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur const cached = this.providerVisibleStateByPtyId.get(ptyId) const trackedMode = this.providerModeTrackersByPtyId.get(ptyId) if ( + !options.freshCapture && cached?.generation === generation && outputSequence <= cached.sequence && (!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen) @@ -69,25 +77,23 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur } if (trackedMode && !trackedMode.isAlternateScreen) { const headlessState = await this.readHeadlessVisibleTerminalState(ptyId) - return headlessState - ? { ...headlessState, isAlternateScreen: false } - : { - lines: [], - isAlternateScreen: false, - sequence: outputSequence, - generation - } + return headlessState ? { ...headlessState, isAlternateScreen: false } : null } if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) { return null } + const attachmentId = this.getPtyAttachmentId(ptyId) const snapshot = await this.serializeProviderTerminalBuffer( ptyId, { scrollbackRows: 0 }, { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } ) - if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { + if ( + !snapshot || + this.getPtyLifecycleGeneration(ptyId) !== generation || + this.getPtyAttachmentId(ptyId) !== attachmentId + ) { this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) return null } @@ -107,11 +113,18 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur ) { return null } + const screenCapture = this.recordVisibleScreenCapture( + ptyId, + generation, + snapshot.seq, + 'provider' + ) const visibleState: RuntimeVisibleTerminalState = { ...projection, isAlternateScreen: snapshot.alternateScreen ?? false, sequence: snapshot.seq, - generation + generation, + screenCapture } this.providerVisibleStateByPtyId.set(ptyId, visibleState) return visibleState @@ -125,10 +138,12 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur return null } const generation = this.getPtyLifecycleGeneration(ptyId) + const attachmentId = this.getPtyAttachmentId(ptyId) await state.writeChain if ( this.headlessTerminals.get(ptyId) !== state || - this.getPtyLifecycleGeneration(ptyId) !== generation + this.getPtyLifecycleGeneration(ptyId) !== generation || + this.getPtyAttachmentId(ptyId) !== attachmentId ) { return null } @@ -136,7 +151,13 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur ...projectTerminalVisibleLines(state.emulator), isAlternateScreen: state.emulator.isAlternateScreen, sequence: state.outputSequence, - generation + generation, + screenCapture: this.recordVisibleScreenCapture( + ptyId, + generation, + state.outputSequence, + 'headless' + ) } } @@ -183,9 +204,46 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur if (!snapshot || snapshot.data.length === 0) { return { lines: [] } } - return this.parseVisibleSnapshot(snapshot) + const generation = this.getPtyLifecycleGeneration(ptyId) + const attachmentId = this.getPtyAttachmentId(ptyId) + const outputSequence = + typeof snapshot.seq === 'number' ? snapshot.seq : this.getPtyOutputSequence(ptyId) + const projection = await this.parseVisibleSnapshot(snapshot) + if ( + this.getPtyLifecycleGeneration(ptyId) !== generation || + this.getPtyAttachmentId(ptyId) !== attachmentId || + this.getPtyOutputSequence(ptyId) > outputSequence + ) { + return { lines: [] } + } + return { + ...projection, + screenCapture: this.recordVisibleScreenCapture( + ptyId, + generation, + outputSequence, + 'renderer' + ) + } } catch { return { lines: [] } } } + + protected recordVisibleScreenCapture( + ptyId: string, + generation: number, + outputSequence: number, + source: RuntimeScreenCapture['source'] + ): RuntimeScreenCapture { + const capture: RuntimeScreenCapture = { + attachmentId: this.getPtyAttachmentId(ptyId), + generation, + outputSequence, + revision: this.nextVisibleScreenCaptureRevision++, + source + } + this.visibleScreenCaptureByPtyId.set(ptyId, capture) + return capture + } } diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 23a82565eb4..aae224ae2ae 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -187,6 +187,8 @@ export type RuntimeProviderSnapshotReadOptions = { timeoutMs?: number retireOnTimeout?: boolean visibleScreenOnly?: boolean + /** Force an actual screen capture; cache reuse keeps the prior provenance. */ + freshVisibleCapture?: boolean } /** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */ diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index 2c8e2fc6143..7fe00b1d90b 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -17,6 +17,7 @@ import { type TuiIdleEvidenceRecord } from './tui-idle-evidence' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeScreenCapture } from './orca-runtime-core' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' @@ -29,6 +30,9 @@ type RuntimeTerminalIdlePollDependencies = { getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus getAttachmentId?(ptyId: string | null | undefined): string | null + getScreenCapture?(ptyId: string | null | undefined): RuntimeScreenCapture | null + getTerminalProcessIncarnation?(handle: string): string | null + retire?(waiter: TerminalWaiter, reason: string): void /** Re-read the record the waiter registered against; see `liveLeaf` below. */ getLiveLeaf(leaf: RuntimeLeafRecord): RuntimeLeafRecord resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void @@ -91,6 +95,14 @@ export class RuntimeTerminalIdlePolls { return } const { waiter } = entry + if ( + this.deps.getTerminalProcessIncarnation && + waiter.processIncarnation !== this.deps.getTerminalProcessIncarnation(waiter.handle) + ) { + this.stop(entry) + this.deps.retire?.(waiter, 'terminal_handle_stale') + return + } // Why re-read: `syncWindowGraph` rebuilds `this.leaves` with fresh objects on every // renderer publish, so the record captured at registration stops advancing. Reading the // live record keeps readiness and first-party status tied to the current attachment. @@ -111,7 +123,8 @@ export class RuntimeTerminalIdlePolls { const observation = observeTuiIdle({ record: { ...leaf, - attachmentId: this.deps.getAttachmentId?.(leaf.ptyId) ?? null + attachmentId: this.deps.getAttachmentId?.(leaf.ptyId) ?? null, + screenCapture: this.deps.getScreenCapture?.(leaf.ptyId) ?? null } satisfies TuiIdleEvidenceRecord, rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => promptAgent !== null, @@ -141,6 +154,14 @@ export class RuntimeTerminalIdlePolls { return } const { waiter, pty } = entry + if ( + this.deps.getTerminalProcessIncarnation && + waiter.processIncarnation !== this.deps.getTerminalProcessIncarnation(waiter.handle) + ) { + this.stop(entry) + this.deps.retire?.(waiter, 'terminal_handle_stale') + return + } // Why no re-read here: `ptysById` has a single create-once `set` site, so PTY // records are mutated in place rather than swapped, and a capture stays live. const agent = this.deps.getPaneAgent(pty.ptyId) @@ -162,7 +183,8 @@ export class RuntimeTerminalIdlePolls { record: { ...pty, lastOscTitleObservedAt: pty.lastOscTitleEpochMs, - attachmentId: pty.incarnationId + attachmentId: this.deps.getAttachmentId?.(pty.ptyId) ?? pty.incarnationId, + screenCapture: this.deps.getScreenCapture?.(pty.ptyId) ?? null } satisfies TuiIdleEvidenceRecord, rendererTitle: adoptedTitle, readPositiveBodyEvidence: () => adoptedIdle || promptAgent !== null, diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 9f02212f4ce..ec7eef16abb 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -14,6 +14,7 @@ import type { TerminalTailWaitState } from './terminal-wait-tail-state' import type { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { RuntimeScreenCapture } from './orca-runtime-core' type RuntimeTerminalTailState = { tailBuffer: string[] @@ -71,7 +72,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { /** Latest first-party state from the agent's own OSC 9999 status stream — what the * agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its * OSC title. Optional: absent until a payload lands. */ - lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null + lastExplicitAgentStatus?: { + state: AgentStatusState + updatedAt: number + outputSequence?: number + attachmentId?: string | null + } | null lastAgentStatusStartedAtEpochMs: number | null lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null @@ -116,6 +122,7 @@ export type RuntimeVisibleTerminalState = { isAlternateScreen: boolean sequence: number generation: number + screenCapture: RuntimeScreenCapture } export type ProviderBufferAcquisition = { diff --git a/src/main/runtime/runtime-terminal-wait-evidence.ts b/src/main/runtime/runtime-terminal-wait-evidence.ts index c6ef2ec4a07..c901f1f7e73 100644 --- a/src/main/runtime/runtime-terminal-wait-evidence.ts +++ b/src/main/runtime/runtime-terminal-wait-evidence.ts @@ -3,6 +3,7 @@ import type { RuntimeTerminalReadiness } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' import { detectKnownReadyPromptAgent } from './terminal-wait-detection' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { RuntimeScreenCapture } from './orca-runtime-core' import { observeTuiIdle, captureTuiIdleEvidenceCursor, @@ -18,6 +19,7 @@ type RuntimeTerminalWaitEvidenceDependencies = { getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus getAttachmentId?(ptyId: string | null | undefined): string | null + getScreenCapture?(ptyId: string | null | undefined): RuntimeScreenCapture | null } export class RuntimeTerminalWaitEvidence { @@ -43,7 +45,8 @@ export class RuntimeTerminalWaitEvidence { record: { ...pty, lastOscTitleObservedAt: pty.lastOscTitleEpochMs, - attachmentId: pty.incarnationId + attachmentId: this.deps.getAttachmentId?.(pty.ptyId) ?? pty.incarnationId, + screenCapture: this.deps.getScreenCapture?.(pty.ptyId) ?? null }, rendererTitle: adoptedTitle, readPositiveBodyEvidence: () => adoptedIdle || promptAgent !== null, @@ -64,7 +67,8 @@ export class RuntimeTerminalWaitEvidence { return observeTuiIdle({ record: { ...leaf, - attachmentId: this.deps.getAttachmentId?.(leaf.ptyId) ?? null + attachmentId: this.deps.getAttachmentId?.(leaf.ptyId) ?? null, + screenCapture: this.deps.getScreenCapture?.(leaf.ptyId) ?? null }, rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => promptAgent !== null, @@ -80,9 +84,10 @@ export class RuntimeTerminalWaitEvidence { { ...pty, lastOscTitleObservedAt: pty.lastOscTitleEpochMs, - attachmentId: pty.incarnationId + attachmentId: this.deps.getAttachmentId?.(pty.ptyId) ?? pty.incarnationId, + screenCapture: this.deps.getScreenCapture?.(pty.ptyId) ?? null }, - pty.incarnationId + this.deps.getAttachmentId?.(pty.ptyId) ?? pty.incarnationId ) } @@ -90,7 +95,8 @@ export class RuntimeTerminalWaitEvidence { return captureTuiIdleEvidenceCursor( { ...leaf, - attachmentId: this.deps.getAttachmentId?.(leaf.ptyId) ?? null + attachmentId: this.deps.getAttachmentId?.(leaf.ptyId) ?? null, + screenCapture: this.deps.getScreenCapture?.(leaf.ptyId) ?? null }, this.deps.getAttachmentId?.(leaf.ptyId) ?? null ) diff --git a/src/main/runtime/runtime-terminal-wait-timeouts.ts b/src/main/runtime/runtime-terminal-wait-timeouts.ts index 0780ea48656..b2a78e5de52 100644 --- a/src/main/runtime/runtime-terminal-wait-timeouts.ts +++ b/src/main/runtime/runtime-terminal-wait-timeouts.ts @@ -8,6 +8,7 @@ import type { TuiIdleEvidenceCursor } from './tui-idle-evidence' type RuntimeTerminalWaitTimeoutDependencies = { getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord } + getTerminalProcessIncarnation?(handle: string): string | null } export function resolvePtyTuiIdleTimeout( @@ -16,8 +17,16 @@ export function resolvePtyTuiIdleTimeout( reject: (error: Error) => void, deps: RuntimeTerminalWaitTimeoutDependencies, evidence: RuntimeTerminalWaitEvidence, - evidenceCursor?: TuiIdleEvidenceCursor + evidenceCursor?: TuiIdleEvidenceCursor, + expectedProcessIncarnation?: string | null ): void { + if ( + deps.getTerminalProcessIncarnation && + expectedProcessIncarnation !== deps.getTerminalProcessIncarnation(handle) + ) { + reject(new Error('terminal_handle_stale')) + return + } const live = deps.getLivePty(handle) if (!live) { reject(new Error('terminal_handle_stale')) @@ -45,8 +54,16 @@ export function resolveLeafTuiIdleTimeout( reject: (error: Error) => void, deps: RuntimeTerminalWaitTimeoutDependencies, evidence: RuntimeTerminalWaitEvidence, - evidenceCursor?: TuiIdleEvidenceCursor + evidenceCursor?: TuiIdleEvidenceCursor, + expectedProcessIncarnation?: string | null ): void { + if ( + deps.getTerminalProcessIncarnation && + expectedProcessIncarnation !== deps.getTerminalProcessIncarnation(handle) + ) { + reject(new Error('terminal_handle_stale')) + return + } let current: RuntimeLeafRecord try { current = deps.getLiveLeaf(handle).leaf diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index bb77604899a..b359d5492a2 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -120,7 +120,8 @@ export class RuntimeTerminalWait { reject, this.deps, this.evidence, - waiter.evidenceCursor + waiter.evidenceCursor, + waiter.processIncarnation ) }, effectiveTimeoutMs) } @@ -159,7 +160,13 @@ export class RuntimeTerminalWait { ) } else { this.polls.startPty(waiter, live.pty) - if (live.pty.lastAgentStatus === null && livePtyWaitText.length === 0) { + // A fresh screen capture can prove an unchanged ready CLI even when + // retained stream text is non-empty; generic bytes still never count + // as readiness without the provider's positive matcher. + if ( + live.pty.lastAgentStatus !== 'working' && + live.pty.lastAgentStatus !== 'permission' + ) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) } } @@ -237,7 +244,8 @@ export class RuntimeTerminalWait { reject, this.deps, this.evidence, - waiter.evidenceCursor + waiter.evidenceCursor, + waiter.processIncarnation ) }, effectiveTimeoutMs) } @@ -276,7 +284,10 @@ export class RuntimeTerminalWait { ) } else { this.polls.startLeaf(waiter, live.leaf) - if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { + if ( + live.leaf.lastAgentStatus !== 'working' && + live.leaf.lastAgentStatus !== 'permission' + ) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) } } diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index a72853d5679..5f6f33c242a 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -12,6 +12,7 @@ import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' import type { AgentStatus } from '../../shared/agent-detection' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { RuntimeScreenCapture } from './orca-runtime-core' import type { FirstPartyAgentStatus } from './tui-idle-evidence' import { captureTuiIdleEvidenceCursor, @@ -40,6 +41,7 @@ function createWait(options: { agent?: TuiAgent | null firstPartyStatus?: FirstPartyAgentStatus liveLeaf?: () => RuntimeLeafRecord + screenCapture?: RuntimeScreenCapture | null }) { const waiters = new RuntimeTerminalWaiterRegistry() const startVisibleReadProbe = vi.fn() @@ -49,6 +51,8 @@ function createWait(options: { getAdoptedPtyTitle: () => options.adoptedTitle ?? null, getPaneAgent: () => options.agent ?? null, getFirstPartyAgentStatus: () => options.firstPartyStatus ?? null, + getAttachmentId: () => 'test-incarnation', + getScreenCapture: () => options.screenCapture ?? null, getTerminalProcessIncarnation: () => 'test-incarnation' } const polls = new RuntimeTerminalIdlePolls({ @@ -127,7 +131,7 @@ describe('tui-idle evidence ranking', () => { const { wait } = createWait({ pty, agent: 'codex' }) const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) pty.lastOscTitle = EXPLICIT_IDLE_TITLE - pty.lastOutputAt = Date.now() + 1 + pty.lastOscTitleAt = 2 await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) await expect(result).resolves.toMatchObject({ satisfied: true }) }) @@ -146,10 +150,21 @@ describe('tui-idle evidence ranking', () => { it('accepts a provider-specific ready screen when launch metadata is absent', async () => { const pty = makeTuiIdlePty() - const { wait } = createWait({ pty, agent: null }) + const screenCapture: RuntimeScreenCapture = { + attachmentId: 'test-incarnation', + generation: 1, + outputSequence: 1, + revision: 1, + source: 'headless' + } + const { wait } = createWait({ + pty, + agent: null, + screenCapture + }) const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) pty.preview = 'OpenAI Codex\nModel: gpt-5\nDirectory: /tmp/repo' - pty.lastOutputAt = Date.now() + 1 + screenCapture.revision = 2 await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) await expect(result).resolves.toMatchObject({ satisfied: true, @@ -166,7 +181,7 @@ describe('tui-idle evidence ranking', () => { adoptedTitle: 'OMP ready' }) const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) - pty.lastOscTitleEpochMs = Date.now() + 1 + pty.lastOscTitleAt = 2 await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) await expect(result).resolves.toMatchObject({ satisfied: true, @@ -197,7 +212,7 @@ describe('tui-idle evidence ranking', () => { }) const { wait } = createWait({ leaf, agent: 'codex', tabTitle: null }) const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) - leaf.lastOutputAt = Date.now() + 1 + leaf.lastOscTitleAt = 2 await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) await expect(result).resolves.toMatchObject({ satisfied: true }) }) @@ -294,6 +309,7 @@ describe('tui-idle evidence ranking', () => { record: { lastAgentStatus: 'idle', lastOscTitle: EXPLICIT_IDLE_TITLE, + lastOscTitleAt: 1, lastOscTitleObservedAt: now, lastOutputAt: now, attachmentId: 'inc-1' @@ -314,6 +330,7 @@ describe('tui-idle evidence ranking', () => { const record: TuiIdleEvidenceRecord = { lastAgentStatus: 'idle', lastOscTitle: EXPLICIT_IDLE_TITLE, + lastOscTitleAt: 1, lastOscTitleObservedAt: now, lastOutputAt: now, attachmentId: 'inc-1' @@ -330,7 +347,7 @@ describe('tui-idle evidence ranking', () => { ).toMatchObject({ state: 'unknown', agent: 'codex' }) expect( observeTuiIdle({ - record: { ...record, lastOscTitleObservedAt: now + 1, lastOutputAt: now + 1 }, + record: { ...record, lastOscTitleAt: 2, lastOscTitleObservedAt: now + 1 }, agent: 'codex', firstPartyStatus: null, evidenceCursor: cursor, @@ -351,7 +368,7 @@ describe('tui-idle evidence ranking', () => { await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) expect(settled).not.toHaveBeenCalled() - pty.lastOutputAt = Date.now() + pty.lastOscTitleAt = 2 await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) await expect(result).resolves.toMatchObject({ satisfied: true }) }) @@ -397,7 +414,13 @@ describe('tui-idle evidence ranking', () => { lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE, lastOutputAt: now, - screenObservedAt: now + 1, + screenCapture: { + attachmentId: 'inc-1', + generation: 1, + outputSequence: 1, + revision: 1, + source: 'headless' + }, attachmentId: 'inc-1' }, agent: 'codex', @@ -408,6 +431,141 @@ describe('tui-idle evidence ranking', () => { }) ).toMatchObject({ state: 'ready', source: 'screen', agent: 'codex' }) }) + + it('rejects a cached screen replay even when a caller wall clock advances', () => { + const capture = { + attachmentId: 'inc-1', + generation: 3, + outputSequence: 42, + revision: 7, + source: 'headless' as const + } + const record: TuiIdleEvidenceRecord = { + lastAgentStatus: 'idle', + lastOscTitle: NAME_ONLY_TITLE, + lastOutputAt: null, + attachmentId: 'inc-1', + screenCapture: capture, + screenObservedAt: Date.now() + 10_000 + } + const cursor = captureTuiIdleEvidenceCursor(record) + expect( + observeTuiIdle({ + record: { ...record, screenObservedAt: Date.now() + 20_000 }, + agent: 'codex', + firstPartyStatus: null, + evidenceCursor: cursor, + readPositiveBodyEvidence: () => true, + positiveBodyEvidenceAgent: 'codex' + }) + ).toMatchObject({ state: 'unknown', agent: 'codex' }) + }) + + it('accepts an unchanged screen only after a new host capture revision', () => { + const capture = { + attachmentId: 'inc-1', + generation: 3, + outputSequence: 42, + revision: 7, + source: 'headless' as const + } + const record: TuiIdleEvidenceRecord = { + lastAgentStatus: 'idle', + lastOscTitle: NAME_ONLY_TITLE, + lastOutputAt: null, + attachmentId: 'inc-1', + screenCapture: capture + } + const cursor = captureTuiIdleEvidenceCursor(record) + expect( + observeTuiIdle({ + record: { ...record, screenCapture: { ...capture, revision: 8 } }, + agent: 'codex', + firstPartyStatus: null, + evidenceCursor: cursor, + readPositiveBodyEvidence: () => true, + positiveBodyEvidenceAgent: 'codex' + }) + ).toMatchObject({ state: 'ready', source: 'screen', agent: 'codex' }) + }) + + it('does not let a recapture of a pre-working screen outrank a stale working fact', () => { + const capture = { + attachmentId: 'inc-1', + generation: 3, + outputSequence: 42, + revision: 8, + source: 'headless' as const + } + const record: TuiIdleEvidenceRecord = { + lastAgentStatus: 'idle', + lastOscTitle: NAME_ONLY_TITLE, + lastOutputAt: null, + attachmentId: 'inc-1', + screenCapture: capture + } + expect( + observeTuiIdle({ + record, + agent: 'codex', + firstPartyStatus: { + state: 'working', + updatedAt: Date.now() - 31 * 60 * 1000, + outputSequence: 42, + attachmentId: 'inc-1' + }, + evidenceCursor: captureTuiIdleEvidenceCursor({ + ...record, + screenCapture: { ...capture, revision: 7 } + }), + readPositiveBodyEvidence: () => true, + positiveBodyEvidenceAgent: 'codex' + }) + ).toMatchObject({ state: 'unknown', source: 'first-party', agent: 'codex' }) + expect( + observeTuiIdle({ + record: { ...record, screenCapture: { ...capture, outputSequence: 43, revision: 9 } }, + agent: 'codex', + firstPartyStatus: { + state: 'working', + updatedAt: Date.now() - 31 * 60 * 1000, + outputSequence: 42, + attachmentId: 'inc-1' + }, + evidenceCursor: captureTuiIdleEvidenceCursor({ + ...record, + screenCapture: { ...capture, revision: 7 } + }), + readPositiveBodyEvidence: () => true, + positiveBodyEvidenceAgent: 'codex' + }) + ).toMatchObject({ state: 'ready', source: 'screen', agent: 'codex' }) + }) + + it('does not treat an unknown attachment as a wildcard for a replacement', () => { + const cursor = captureTuiIdleEvidenceCursor({ + lastAgentStatus: 'idle', + lastOscTitle: NAME_ONLY_TITLE, + lastOutputAt: null, + attachmentId: null, + lastOscTitleAt: 1 + }) + expect( + observeTuiIdle({ + record: { + lastAgentStatus: 'idle', + lastOscTitle: EXPLICIT_IDLE_TITLE, + lastOutputAt: null, + attachmentId: 'inc-replacement', + lastOscTitleAt: 2 + }, + agent: 'codex', + firstPartyStatus: null, + evidenceCursor: cursor, + readPositiveBodyEvidence: () => false + }) + ).toMatchObject({ state: 'unknown', agent: 'codex' }) + }) }) const E2E_WORKTREE_ID = 'repo-1::/tmp/name-only-idle' diff --git a/src/main/runtime/tui-idle-agent-fixture.mjs b/src/main/runtime/tui-idle-agent-fixture.mjs index cc12924f93c..994400b05d3 100644 --- a/src/main/runtime/tui-idle-agent-fixture.mjs +++ b/src/main/runtime/tui-idle-agent-fixture.mjs @@ -11,6 +11,8 @@ const streaming = setInterval(() => { clearInterval(streaming) if (mode === 'explicit-idle') { process.stdout.write(osc('Codex ready')) + } else if (mode === 'ready-screen') { + process.stdout.write('\nOpenAI Codex\nModel: gpt-5\nDirectory: /tmp/tui-idle-real-pty\n') } return } diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 816ed49b93d..f31c7e5d9b8 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -5,6 +5,7 @@ import { getAgentReadinessCapability } from '../../shared/agent-readiness-capabi import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' import type { TuiAgent } from '../../shared/tui-agent' import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' +import type { RuntimeScreenCapture } from './orca-runtime-core' /** * Ranking the evidence that a `tui-idle` wait may settle on. @@ -38,14 +39,18 @@ export type TuiIdleEvidenceRecord = { lastOscTitleObservedAt?: number | null /** Host-owned attachment identity. Historical bytes must never certify a replacement process. */ attachmentId?: string | null - /** Host capture time for a visible-screen read; distinct from PTY stream output time. */ + /** @deprecated Legacy wall-clock field; readiness ignores it in favor of screenCapture. */ screenObservedAt?: number | null + /** Provenance of the visible screen text used by a readiness decision. */ + screenCapture?: RuntimeScreenCapture | null } export type FirstPartyAgentStatus = { state: AgentStatusState /** When the host observed this provider fact, not when a replica replayed it. */ updatedAt: number + /** PTY output sequence at the host observation, when available. */ + outputSequence?: number attachmentId?: string | null } | null @@ -54,7 +59,9 @@ export type FirstPartyAgentStatus = { export type TuiIdleEvidenceCursor = { attachmentId: string | null titleRevision: number | null - screenObservedAt: number | null + screenCaptureRevision: number | null + screenCaptureAttachmentId: string | null + screenCaptureGeneration: number | null } export type TuiIdleObservation = { @@ -133,10 +140,10 @@ export function captureTuiIdleEvidenceCursor( ? record.lastOscTitleAt : typeof record.lastOscTitleObservedAt === 'number' ? record.lastOscTitleObservedAt - : typeof record.lastOutputAt === 'number' - ? record.lastOutputAt - : null, - screenObservedAt: record.screenObservedAt ?? record.lastOutputAt + : null, + screenCaptureRevision: record.screenCapture?.revision ?? null, + screenCaptureAttachmentId: record.screenCapture?.attachmentId ?? null, + screenCaptureGeneration: record.screenCapture?.generation ?? null } } @@ -145,7 +152,12 @@ function hasEvidenceAfter( cursor: TuiIdleEvidenceCursor, source: 'title' | 'screen' ): boolean { - if (cursor.attachmentId !== null && record.attachmentId !== cursor.attachmentId) { + if ( + cursor.attachmentId === null || + record.attachmentId === null || + record.attachmentId === undefined || + record.attachmentId !== cursor.attachmentId + ) { return false } if (source === 'title') { @@ -154,20 +166,24 @@ function hasEvidenceAfter( ? record.lastOscTitleAt : typeof record.lastOscTitleObservedAt === 'number' ? record.lastOscTitleObservedAt - : typeof record.lastOutputAt === 'number' - ? record.lastOutputAt - : null + : null return ( currentRevision !== null && (cursor.titleRevision === null || currentRevision > cursor.titleRevision) ) } - const currentScreenObservation = record.screenObservedAt ?? record.lastOutputAt - return ( - currentScreenObservation !== null && - currentScreenObservation !== undefined && - (cursor.screenObservedAt === null || currentScreenObservation > cursor.screenObservedAt) - ) + const capture = record.screenCapture + if ( + !capture || + capture.attachmentId !== record.attachmentId || + (cursor.screenCaptureAttachmentId !== null && + capture.attachmentId !== cursor.screenCaptureAttachmentId) || + (cursor.screenCaptureGeneration !== null && + capture.generation !== cursor.screenCaptureGeneration) + ) { + return false + } + return cursor.screenCaptureRevision === null || capture.revision > cursor.screenCaptureRevision } function hasEvidenceAfterFirstPartyStatus( @@ -191,8 +207,17 @@ function hasEvidenceAfterFirstPartyStatus( record.lastOscTitleObservedAt > status.updatedAt ) } - const currentScreenObservation = record.screenObservedAt ?? record.lastOutputAt - return typeof currentScreenObservation === 'number' && currentScreenObservation > status.updatedAt + // A screen capture is a host observation, not a replayed stream timestamp. The + // operation cursor fences old captures; once a new same-attachment frame exists, + // it is newer evidence even when the provider screen itself is unchanged. + return Boolean( + record.screenCapture && + (status.attachmentId === undefined || + status.attachmentId === null || + record.screenCapture.attachmentId === status.attachmentId) && + (status.outputSequence === undefined || + record.screenCapture.outputSequence > status.outputSequence) + ) } /** diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 0d265a0a5bb..fe24f7ee97b 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -32,7 +32,10 @@ afterEach(() => { } }) -async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) { +async function startRealAgentPane( + mode: 'explicit-idle' | 'quiet' | 'ready-screen', + workMs: number +) { const child = pty.spawn(process.execPath, [FIXTURE, mode, String(workMs)], { name: 'xterm-256color', cols: 120, @@ -128,13 +131,14 @@ describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty expect(outcome.elapsedMs).toBeGreaterThanOrEqual(1_500) }, 28_000) - it('satisfies once the real process goes quiet with the agent still in foreground', async () => { - const { runtime, handle } = await startRealAgentPane('quiet', 3_000) - await new Promise((resolve) => setTimeout(resolve, 500)) + it('satisfies an unchanged real ready screen through a fresh capture', async () => { + const { runtime, handle } = await startRealAgentPane('ready-screen', 3_000) + await new Promise((resolve) => setTimeout(resolve, 3_500)) const outcome = await terminalWait(runtime, handle, 20_000) expect(outcome.satisfied).toBe(true) - // Corroboration is never instant: quiescence must elapse after the last byte. - expect(outcome.elapsedMs).toBeGreaterThanOrEqual(3_000) + // The body was retained before registration; readiness comes from a current + // host-owned screen capture, not from requiring another PTY byte. + expect(outcome.elapsedMs).toBeLessThan(5_000) }, 28_000) })