mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
Fix C3 readiness capture provenance fencing
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user