Fix C3 readiness capture provenance fencing

This commit is contained in:
Brennan Benson
2026-09-15 18:25:20 -07:00
parent 3b52e97a97
commit 21b8a14d03
21 changed files with 507 additions and 92 deletions
@@ -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<RuntimeTerminalProjection> {
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) {
+19 -1
View File
@@ -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) {
@@ -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<RuntimeVisibleTerminalState | null> }
{
generation: number
freshCapture: boolean
promise: Promise<RuntimeVisibleTerminalState | null>
}
>()
protected providerVisibleRetryAtByPtyId = new Map<string, number>()
/** 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<string, RuntimeScreenCapture>()
protected nextVisibleScreenCaptureRevision = 1
protected providerSnapshotsWithLiveModeTransition = new WeakSet<PtyProviderBufferSnapshot>()
protected ptyLifecycleGenerationById = new Map<string, number>()
@@ -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(
+2 -1
View File
@@ -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)
}
}
}
@@ -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
@@ -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)
}
@@ -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,
+18 -7
View File
@@ -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)
@@ -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,
@@ -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<RuntimeVisibleTerminalState | null> {
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<RuntimeVisibleTerminalState | null> }
const promise = this.loadVisibleTerminalState(ptyId).finally(() => {
let entry: {
generation: number
freshCapture: boolean
promise: Promise<RuntimeVisibleTerminalState | null>
}
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<RuntimeVisibleTerminalState | null> {
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
}
}
@@ -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. */
@@ -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,
@@ -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 = {
@@ -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
)
@@ -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
+15 -4
View File
@@ -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)
}
}
@@ -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'
@@ -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
}
+43 -18
View File
@@ -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)
)
}
/**
@@ -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)
})