feat(agent-status): make readiness evidence explicit

This commit is contained in:
Brennan Benson
2026-09-14 20:36:27 -07:00
parent ffc331212c
commit 56e7df996d
40 changed files with 1080 additions and 589 deletions
@@ -21,7 +21,10 @@ type FakeWaiter = {
timer: ReturnType<typeof setTimeout>
}
function createFakeRuntime(initial: Partial<FakePane>) {
function createFakeRuntime(
initial: Partial<FakePane>,
readinessOptions: { resolveUnknown?: boolean } = {}
) {
const pane: FakePane = {
lastAgentStatus: null,
paneTitle: null,
@@ -44,14 +47,20 @@ function createFakeRuntime(initial: Partial<FakePane>) {
} = {
getTerminalHandleForPaneKey: () => HANDLE,
readTerminal: async () => ({ tail: ['previous run output'] }),
waitForTerminal: (_handle, options) => {
waitForTerminal: (_handle, waitOptions) => {
waitCalls += 1
if (options?.signal?.aborted) {
if (waitOptions?.signal?.aborted) {
return Promise.reject(new Error('request_aborted'))
}
if (satisfiedNow()) {
return Promise.resolve({ satisfied: true })
}
if (readinessOptions.resolveUnknown) {
return Promise.resolve({
satisfied: false,
readiness: { state: 'unknown' as const }
})
}
return new Promise((resolve, reject) => {
const waiter: FakeWaiter = {
resolve,
@@ -59,7 +68,7 @@ function createFakeRuntime(initial: Partial<FakePane>) {
timer: setTimeout(() => {
waiters.delete(waiter)
reject(new Error('timeout'))
}, options?.timeoutMs ?? RUNTIME_TUI_IDLE_TIMEOUT_MS)
}, waitOptions?.timeoutMs ?? RUNTIME_TUI_IDLE_TIMEOUT_MS)
}
waiters.add(waiter)
})
@@ -167,6 +176,26 @@ describe('createRuntimeAutomationRunTerminalObserver', () => {
await run.promise
})
it('does not treat an explicit unknown readiness result as proof the run started', async () => {
const readiness = { resolveUnknown: true }
const runtime = createFakeRuntime({}, readiness)
const run = observe(runtime)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(10_000)
expect(run.settled).toEqual([])
readiness.resolveUnknown = false
runtime.setPane({ lastAgentStatus: 'working' })
await vi.advanceTimersByTimeAsync(1_000)
expect(run.settled).toEqual([])
runtime.setPane({ lastAgentStatus: 'idle' })
await vi.advanceTimersByTimeAsync(10)
expect(run.settled[0]?.status).toBe('completed')
await run.promise
})
it('completes a fresh launch that was never idle at dispatch', async () => {
const runtime = createFakeRuntime({ lastAgentStatus: 'working' })
const run = observe(runtime)
@@ -30,7 +30,11 @@ export type AutomationRunTerminalHost = {
waitForTerminal(
handle: string,
options?: { condition?: 'tui-idle'; timeoutMs?: number; signal?: AbortSignal }
): Promise<{ satisfied: boolean; blockedReason?: string }>
): Promise<{
satisfied: boolean
blockedReason?: string
readiness?: { state?: 'ready' | 'blocked' | 'busy' | 'unsupported' | 'unknown' }
}>
readTerminal(handle: string, opts?: { limit?: number }): Promise<{ tail: string[] }>
}
@@ -65,15 +69,23 @@ async function isTuiIdleSatisfiedNow(
runtime: AutomationRunTerminalHost,
handle: string,
signal: AbortSignal
): Promise<boolean> {
): Promise<boolean | null> {
try {
const wait = await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs: AGENT_START_PROBE_TIMEOUT_MS,
signal
})
// A blocked pane is not "already finished"; let the real wait report it.
return wait.satisfied
// A blocked pane is not "already finished"; let the real wait report it. An explicit unknown
// result is different from the legacy timeout rejection: it is not evidence that this run
// started, so the caller must keep looking for an attributable turn edge.
if (wait.satisfied) {
return true
}
if (wait.readiness?.state === 'unknown' || wait.readiness?.state === 'unsupported') {
return null
}
return false
} catch (error) {
if (isTerminalWaitTimeout(error)) {
return false
@@ -92,7 +104,8 @@ async function waitForAgentStart(
): Promise<boolean> {
while (Date.now() < deadlineAt) {
await sleep(AGENT_START_POLL_INTERVAL_MS, signal)
if (!(await isTuiIdleSatisfiedNow(runtime, handle, signal))) {
const idle = await isTuiIdleSatisfiedNow(runtime, handle, signal)
if (idle === false) {
return true
}
}
@@ -158,7 +171,8 @@ export function createRuntimeAutomationRunTerminalObserver(
// Evidence that predates dispatch proves nothing about this run, so require
// the pane to leave that state first — the busy edge the renderer's own
// dispatch observer requires on reuse (requireWorkingAfterStart).
if (await isTuiIdleSatisfiedNow(runtime, handle, signal)) {
const idle = await isTuiIdleSatisfiedNow(runtime, handle, signal)
if (idle !== false) {
const started = await waitForAgentStart(
runtime,
handle,
@@ -177,6 +191,19 @@ export function createRuntimeAutomationRunTerminalObserver(
for (;;) {
try {
const wait = await runtime.waitForTerminal(handle, { condition: 'tui-idle', signal })
// New hosts settle a bounded wait with an explicit unknown/busy readiness facet instead
// of rejecting `timeout`. Keep the observer's historical re-arm semantics for that
// non-terminal result; only an actionable interaction is a completion failure.
if (!wait.satisfied && !wait.blockedReason) {
if (Date.now() >= deadlineAt) {
return await buildUnobservedObservation(
runtime,
handle,
'Orca stopped watching this run after 6h without a completion signal.'
)
}
continue
}
return await buildObservation(runtime, handle, wait)
} catch (error) {
// Why: tui-idle waits expire on their own schedule; an agent still
@@ -1,12 +1,11 @@
export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets'
import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets'
import type { TuiAgent } from '../../shared/tui-agent'
import { supportsAgentPromptTurnStart } from '../../shared/agent-readiness-capabilities'
export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS
const AGENT_PROMPT_EFFECT_POLL_MS = 50
const HOOK_OBSERVED_TURN_START_AGENTS = new Set<TuiAgent>(['codex', 'kimi'])
/** The prompt bytes are written before verification, so this only ever means "not observed". */
export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled'
@@ -45,7 +44,7 @@ type AgentPromptVerificationOptions = {
}
export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undefined): number {
return agent && HOOK_OBSERVED_TURN_START_AGENTS.has(agent)
return agent && supportsAgentPromptTurnStart(agent)
? AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS
: AGENT_PROMPT_EFFECT_TIMEOUT_MS
}
@@ -54,7 +53,7 @@ export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undef
export function isTerminalSendSettlementAgent(
agent: TuiAgent | null | undefined
): agent is 'claude' | 'codex' {
return agent === 'claude' || agent === 'codex'
return (agent === 'claude' || agent === 'codex') && supportsAgentPromptTurnStart(agent)
}
export function isAgentPromptStalledError(error: unknown): boolean {
@@ -1,13 +1,12 @@
/**
* Pins Antigravity readiness to captured transcripts instead of hand-written fixtures.
*
* Five detector attempts were tuned against a five-line screen someone typed from memory, and
* three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what
* Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name
* themselves as skipped until someone can reach them.
* Five detector attempts were tuned against a five-line screen someone typed from memory. Nothing
* here asserts what Antigravity prints: the transcripts do. Six are recorded from a live `agy`;
* the rest name themselves as skipped until someone can reach them.
*
* Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready
* screen and accepts the live model picker. Those assert what it does, not what it should.
* Antigravity is intentionally unsupported for positive automated readiness: the captured ready
* screen is evidence for the detector's shape, not a complete startup/account/mode contract.
*
* Capture protocol: docs/reference/agent-pty-transcript-capture.md
* What each transcript decides: docs/reference/antigravity-readiness-evidence.md
@@ -51,14 +50,8 @@ type TranscriptCase = {
/** Capture in docs/reference/antigravity-readiness-evidence.md. */
capture: string
what: string
/** What a correct detector must answer. Not what the shipped one answers. */
/** Whether this launch path has a supported positive readiness contract. */
expectReady: boolean
/**
* Set where the shipped detector contradicts the transcript. The case then runs inverted, so
* CI pins the defect instead of going permanently red — and flips to failing the moment
* someone fixes it, which is exactly when these expectations need re-reading.
*/
knownDefect?: string
}
const TRANSCRIPTS: readonly TranscriptCase[] = [
@@ -66,15 +59,13 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
name: 'antigravity-ready-api-key-gemini-model',
capture: 'B',
what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email',
expectReady: true,
knownDefect: 'refused: the model row never starts a line, the logo shares it'
expectReady: false
},
{
name: 'antigravity-ready-account-info-hidden',
capture: 'B',
what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all',
expectReady: true,
knownDefect: 'refused: same line-start defect, and no account row exists to require'
expectReady: false
},
{
name: 'antigravity-dialog-trust-workspace',
@@ -86,8 +77,7 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
name: 'antigravity-dialog-model-picker',
capture: 'C',
what: 'model picker owning the screen',
expectReady: false,
knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule"
expectReady: false
},
{
name: 'antigravity-dialog-command-palette',
@@ -102,20 +92,16 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
expectReady: false
},
{
// Expected ready because the turn is over and the composer is back on screen. The captured
// turn ends in a backend error, which is the only ending this account's key can produce.
name: 'antigravity-busy-turn-ended',
capture: 'E',
what: 'the turn has ended and the composer has returned, process still alive',
expectReady: true,
knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it'
expectReady: false
},
{
name: 'antigravity-dialog-dismissed',
capture: 'D',
what: 'the screen immediately after the model picker is dismissed',
expectReady: true,
knownDefect: 'refused: the banner is not reprinted and no model row starts a line'
expectReady: false
},
// Not captured: this machine's agy has no OAuth session and offers only Gemini models, and
// reaching the rest would mean signing the operator out or deleting their config. See
@@ -124,7 +110,7 @@ const TRANSCRIPTS: readonly TranscriptCase[] = [
name: 'antigravity-ready-business-non-gemini',
capture: 'A',
what: 'ready screen, Business account, non-Gemini model',
expectReady: true
expectReady: false
},
{
name: 'antigravity-dialog-sign-in',
@@ -157,15 +143,18 @@ function fixturePath(name: string): string {
}
/**
* A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it
* resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever
* looked ready. The orchestrator treats the last two identically — no prompt is delivered — so
* they are both `ready: false` here. This is the shape `worker-start` sees.
* A `tui-idle` wait resolves with an explicit readiness facet. Unsupported or unobserved
* Antigravity evidence is never promoted to `satisfied` and cannot unlock a prompt write.
*/
async function readinessVerdict(
transcript: string,
timeoutMs: number
): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> {
): Promise<{
ready: boolean
blockedReason: unknown
outcome: string
readinessState: string | null
}> {
const { runtime, handle } = await createTranscriptPane({
// Why the transcript's own title: every attempt guessed at Antigravity's title. A raw
// capture carries the OSC bytes, so the pane wears whatever the CLI actually set.
@@ -174,17 +163,23 @@ async function readinessVerdict(
data: transcript
})
try {
const result = (await runtime.waitForTerminal(handle, {
const result = await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs
})) as { satisfied?: boolean; blockedReason?: unknown }
})
return {
ready: result.satisfied === true,
blockedReason: result.blockedReason ?? null,
outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied'
outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied',
readinessState: result.readiness?.state ?? null
}
} catch (error) {
return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` }
return {
ready: false,
blockedReason: null,
outcome: `rejected: ${String(error)}`,
readinessState: null
}
}
}
@@ -194,15 +189,7 @@ describe('Antigravity readiness, decided by captured transcripts', () => {
const captured = existsSync(path)
const label = `capture ${transcript.capture}: ${transcript.what}`
// A pinned defect asserts what the detector DOES, so CI is honest rather than permanently
// red; fixing the detector flips this case to failing, which is when these expectations
// need re-reading. The correct answer stays in `expectReady` and in the test's name.
const shipped =
transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady
const verdictName =
transcript.knownDefect === undefined
? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}`
: `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}`
const verdictName = `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}`
it.skipIf(!captured)(
verdictName,
@@ -216,7 +203,7 @@ describe('Antigravity readiness, decided by captured transcripts', () => {
// A silent dialog carries no blocked-signal wording, so the assertion is only that Orca
// does not call the pane ready and type a prompt into a dialog that owns the screen.
expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({
ready: shipped
ready: transcript.expectReady
})
},
READY_TIMEOUT_MS + 10_000
@@ -257,7 +244,7 @@ describe('scaffold self-check', () => {
// capture disagreed with the detector — not that the harness or the timeouts are broken.
// Neither case is evidence about Antigravity; both are shapes the current detector already
// decides, used only to prove the plumbing reaches a verdict.
it('reaches a ready verdict through the harness', async () => {
it('keeps an Antigravity-shaped ready screen unsupported through the harness', async () => {
const verdict = await readinessVerdict(
[
'Antigravity CLI 1.0.3',
@@ -268,7 +255,7 @@ describe('scaffold self-check', () => {
].join('\n'),
READY_TIMEOUT_MS
)
expect(verdict.ready).toBe(true)
expect(verdict.ready).toBe(false)
})
it('reaches a not-ready verdict through the harness', async () => {
@@ -62,8 +62,6 @@ export const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
export const TUI_IDLE_POLL_INTERVAL_MS = 2000
export const TUI_IDLE_QUIESCENCE_MS = 3000
// Clamp for mobileAutoRestoreFitMs: floor above the legacy 300ms debounce, 1h ceiling (a held PTY beyond that is "I forgot", not intentional).
export const MOBILE_AUTO_RESTORE_FIT_MIN_MS = 5_000
@@ -6,11 +6,10 @@ import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal-
import type { AgentStatus } from '../../shared/agent-detection'
import {
detectExplicitIdleStatusFromTitle,
isKnownReadyPromptPreview
detectKnownReadyPromptAgent
} from './terminal-wait-detection'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import { isTuiIdleSatisfied } from './tui-idle-evidence'
import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude'
import { observeTuiIdle, type TuiIdleObservation } from './tui-idle-evidence'
export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle {
protected resolveExitWaiters(leaf: RuntimeLeafRecord): void {
@@ -49,15 +48,22 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
if (!waiters || waiters.size === 0) {
return
}
// Why re-rank rather than resolve outright: the transition that brought us here is
// only a title sample, and a name-only title arriving mid-turn is the weakest tier
// there is (#6011). Leave such a waiter on its poll to be corroborated instead.
if (!this.isTuiIdleSatisfiedForLeaf(leaf)) {
// A title transition is only usable when the shared evidence evaluator identifies a
// provider-supported readiness fact; name-only or otherwise unbound observations stay open.
const observation = this.observeTuiIdleForLeaf(leaf)
if (observation.state !== 'ready') {
return
}
for (const waiter of [...waiters]) {
if (waiter.condition === 'tui-idle') {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf))
this.resolveWaiter(
waiter,
buildTerminalWaitResult(handle, 'tui-idle', leaf, {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
})
)
}
}
}
@@ -91,86 +97,47 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
return
}
// Why: same re-ranking as resolveTuiIdleWaiters above.
if (!this.isTuiIdleSatisfiedForPty(pty)) {
const observation = this.observeTuiIdleForPty(pty)
if (observation.state !== 'ready') {
return
}
for (const waiter of [...waiters]) {
if (waiter.condition === 'tui-idle') {
this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty))
this.resolveWaiter(
waiter,
buildPtyTerminalWaitResult(handle, 'tui-idle', pty, {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
})
)
}
}
}
// Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires.
// Why: daemon-hosted terminals may have no PTY bytes; a title or screen fact can still prove readiness.
protected isTuiIdleSatisfiedForLeaf(leaf: RuntimeLeafRecord): boolean {
return isTuiIdleSatisfied({
return this.observeTuiIdleForLeaf(leaf).state === 'ready'
}
protected observeTuiIdleForLeaf(leaf: RuntimeLeafRecord): TuiIdleObservation {
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const promptAgent = detectKnownReadyPromptAgent(waitText)
return observeTuiIdle({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null,
readPositiveBodyEvidence: () =>
isKnownReadyPromptPreview(
buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
),
readPositiveBodyEvidence: () => promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
agent: this.getPaneAgentForTuiIdle(leaf.ptyId),
firstPartyStatus:
(leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null,
quiescenceMs: TUI_IDLE_QUIESCENCE_MS
(leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null
})
}
/**
* Settled-enough-to-type check that also arms a retry when it says no.
*
* Why the retry: the wait path POLLS, so weak evidence that only becomes valid with the
* passage of time (a pane going quiet) eventually satisfies it. Delivery is edge-driven —
* a title transition, a graph sync, a new message — with no poll behind it, so a refusal
* at an edge is final unless another edge happens to arrive. A hookless Codex pane never
* emits an explicit `X ready`, so the refusal below would strand the queued message
* permanently once the pane fell quiet. One-shot timer, armed only for a leaf that
* actually refused, cleared as soon as any path delivers.
*/
protected checkDeliverySettledAndArmRecheck(leaf: { tabId: string; leafId: string }): boolean {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
if (this.isAgentSettledForDelivery(leaf)) {
this.clearDeliveryRecheck(leafKey)
return true
}
this.armDeliveryRecheck(leafKey)
return false
}
protected clearDeliveryRecheck(leafKey: string): void {
const timer = this.deliveryRecheckTimersByLeafKey.get(leafKey)
if (timer) {
clearTimeout(timer)
this.deliveryRecheckTimersByLeafKey.delete(leafKey)
}
}
private armDeliveryRecheck(leafKey: string): void {
if (this.deliveryRecheckTimersByLeafKey.has(leafKey)) {
return
}
const live = this.leaves.get(leafKey)
// Why this delay: the only refusal that time alone can lift is tier 3 waiting on the
// stream to go quiet, so wake just after the window could have elapsed. A pane that is
// still producing output re-arms from its own fresher timestamp rather than spinning.
const elapsed = live?.lastOutputAt ? Date.now() - live.lastOutputAt : 0
const delay = Math.max(TUI_IDLE_QUIESCENCE_MS - elapsed, 0) + 50
const timer = setTimeout(() => {
this.deliveryRecheckTimersByLeafKey.delete(leafKey)
const current = this.leaves.get(leafKey)
if (!current) {
return
}
// Why the gate again here: delivery sites gate at the CALL, not inside
// deliverPendingMessagesForLeaf, so firing straight into it would hand the retry the
// very injection the gate exists to prevent. A pane that went busy again re-arms.
if (this.checkDeliverySettledAndArmRecheck(current)) {
this.deliverPendingMessagesForLeaf(current)
}
}, delay)
timer.unref?.()
this.deliveryRecheckTimersByLeafKey.set(leafKey, timer)
// Readiness is evidence-driven. A failed check stays parked until a new title, screen, or
// hook fact arrives; elapsed silence is never allowed to unlock a write into a TUI.
return this.isAgentSettledForDelivery(leaf)
}
/**
@@ -188,16 +155,22 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc
}
protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean {
return isTuiIdleSatisfied({
return this.observeTuiIdleForPty(pty).state === 'ready'
}
protected observeTuiIdleForPty(pty: RuntimePtyWorktreeRecord): TuiIdleObservation {
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
const promptAgent = detectKnownReadyPromptAgent(waitText)
const adoptedIdle = this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle'
const adoptedTitle = this.getAdoptedPtyTitle(pty)
return observeTuiIdle({
record: pty,
readPositiveBodyEvidence: () =>
this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' ||
isKnownReadyPromptPreview(
buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
),
rendererTitle: adoptedTitle,
readPositiveBodyEvidence: () => adoptedIdle || promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
positiveBodyEvidenceSource: adoptedIdle ? 'title' : 'screen',
agent: this.getPaneAgentForTuiIdle(pty.ptyId),
firstPartyStatus: pty.lastExplicitAgentStatus ?? null,
quiescenceMs: TUI_IDLE_QUIESCENCE_MS
firstPartyStatus: pty.lastExplicitAgentStatus ?? null
})
}
+5 -11
View File
@@ -46,11 +46,7 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin
import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
import { RuntimeTerminalWriter } from './runtime-terminal-writer'
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
import {
TUI_IDLE_DEFAULT_TIMEOUT_MS,
TUI_IDLE_POLL_INTERVAL_MS,
TUI_IDLE_QUIESCENCE_MS
} from './orca-runtime-postlude'
import { TUI_IDLE_DEFAULT_TIMEOUT_MS, TUI_IDLE_POLL_INTERVAL_MS } from './orca-runtime-postlude'
import { RuntimeTerminalWait as RuntimeTerminalWaitController } from './runtime-terminal-wait'
import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict'
@@ -274,9 +270,6 @@ export class OrcaRuntimeWithRuntimeId {
return pty?.launchAgent ?? pty?.foregroundAgent ?? null
}
/** One-shot delivery retries, keyed by leaf. See checkDeliverySettledAndArmRecheck. */
protected deliveryRecheckTimersByLeafKey = new Map<string, ReturnType<typeof setTimeout>>()
protected leaves = new Map<string, RuntimeLeafRecord>()
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
@@ -327,13 +320,13 @@ export class OrcaRuntimeWithRuntimeId {
protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({
intervalMs: TUI_IDLE_POLL_INTERVAL_MS,
quiescenceMs: TUI_IDLE_QUIESCENCE_MS,
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null,
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,
getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle),
getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf,
resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result)
})
@@ -344,11 +337,12 @@ export class OrcaRuntimeWithRuntimeId {
getLivePty: (handle) => this.getLivePtyForHandle(handle),
getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle),
getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty),
getAdoptedPtyTitle: (pty) => this.getAdoptedPtyTitle(pty),
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
quiescenceMs: TUI_IDLE_QUIESCENCE_MS,
getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId),
getFirstPartyAgentStatus: (ptyId) =>
(ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null,
getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle),
startVisibleReadProbe: (waiter, waiterTimeoutMs) =>
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs)
},
@@ -11,8 +11,10 @@ import {
import { withTimeout } from './runtime-async-boundaries'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptPreview
detectKnownReadyPromptAgent,
type KnownReadyPromptAgent
} from './terminal-wait-detection'
import { observeTuiIdle } from './tui-idle-evidence'
import type {
RuntimeTerminalWait,
RuntimeTerminalWaitBlockedReason
@@ -62,16 +64,19 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
if (
!projection ||
projection.source !== 'screen' ||
!this.terminalWaiters.get(waiter.handle)?.has(waiter)
!this.terminalWaiters.get(waiter.handle)?.has(waiter) ||
waiter.processIncarnation === null ||
this.getTerminalProcessIncarnation(waiter.handle) !== waiter.processIncarnation
) {
return
}
const snapshotText = projection.tail.join('\n')
const blockedReason = detectTerminalWaitBlockedReason(snapshotText)
if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) {
const promptAgent = detectKnownReadyPromptAgent(snapshotText)
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason, promptAgent)
if (!result) {
return
}
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason)
if (waiter.cancelIdlePoll) {
waiter.cancelIdlePoll()
}
@@ -82,18 +87,52 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
protected buildTuiIdleProbeResult(
handle: string,
blockedReason: RuntimeTerminalWaitBlockedReason | null
): RuntimeTerminalWait {
blockedReason: RuntimeTerminalWaitBlockedReason | null,
promptAgent: KnownReadyPromptAgent | null
): RuntimeTerminalWait | null {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
return blockedReason
? buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty.pty, blockedReason)
: buildPtyTerminalWaitResult(handle, 'tui-idle', pty.pty)
if (blockedReason) {
return buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty.pty, blockedReason)
}
const observation = observeTuiIdle({
record: pty.pty,
rendererTitle: this.getAdoptedPtyTitle(pty.pty),
readPositiveBodyEvidence: () => promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
positiveBodyEvidenceSource: 'screen',
agent: this.getPaneAgentForTuiIdle(pty.pty.ptyId),
firstPartyStatus: pty.pty.lastExplicitAgentStatus ?? null
})
return observation.state === 'ready'
? buildPtyTerminalWaitResult(handle, 'tui-idle', pty.pty, {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
})
: null
}
const { leaf } = this.getLiveLeafForHandle(handle)
return blockedReason
? buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, blockedReason)
: buildTerminalWaitResult(handle, 'tui-idle', leaf)
if (blockedReason) {
return buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, blockedReason)
}
const observation = observeTuiIdle({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null,
readPositiveBodyEvidence: () => promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
positiveBodyEvidenceSource: 'screen',
agent: this.getPaneAgentForTuiIdle(leaf.ptyId),
firstPartyStatus:
(leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null
})
return observation.state === 'ready'
? buildTerminalWaitResult(handle, 'tui-idle', leaf, {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
})
: null
}
async waitForSetupTerminalCompletion(handle: string): Promise<{ exitCode: number | null }> {
@@ -183,7 +183,7 @@ describe('OrcaRuntimeService', () => {
}
})
it('repoints on the retry edge when live idle won the send race', async () => {
it('does not repoint when idle status lacks provider-bound readiness', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
@@ -228,10 +228,7 @@ describe('OrcaRuntimeService', () => {
leaf.lastAgentStatusObservedLive = true
await vi.advanceTimersByTimeAsync(2_000)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
expect(write).not.toHaveBeenCalled()
db.close()
} finally {
vi.useRealTimers()
@@ -434,7 +431,7 @@ describe('OrcaRuntimeService', () => {
}
})
it('points already-idle Run mail after Codex replaces its completion title', async () => {
it('keeps Run mail pending after Codex replaces its completion title', async () => {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
@@ -469,12 +466,8 @@ describe('OrcaRuntimeService', () => {
runtime.notifyMessageArrived('run:run_codex_native_title', 'worker_done')
await Promise.resolve()
await vi.waitFor(() => {
expect(write).toHaveBeenCalledWith(
'pty-1',
'\nYou have 1 orchestration message. Run `orca-dev orchestration check --run run_codex_native_title`.\n'
)
})
await Promise.resolve()
expect(write).not.toHaveBeenCalled()
db.close()
})
@@ -19,7 +19,7 @@ import {
} from '../orca-runtime-test-fixtures.spec'
describe('OrcaRuntimeService', () => {
it('tui-idle times out when PTY data has no agent OSC title transitions', async () => {
it('tui-idle returns unknown when PTY data has no readiness evidence', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
@@ -52,11 +52,14 @@ describe('OrcaRuntimeService', () => {
condition: 'tui-idle',
timeoutMs: 1_000
})
const timeoutAssertion = expect(waitPromise).rejects.toThrow('timeout')
const unknownAssertion = expect(waitPromise).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
await vi.advanceTimersByTimeAsync(12_000)
await timeoutAssertion
await unknownAssertion
} finally {
vi.useRealTimers()
}
@@ -453,11 +453,14 @@ describe('OrcaRuntimeService', () => {
condition: 'tui-idle',
timeoutMs: 1_000
})
const timeoutAssertion = expect(waitPromise).rejects.toThrow('timeout')
const unknownAssertion = expect(waitPromise).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
await vi.advanceTimersByTimeAsync(2_000)
await timeoutAssertion
await unknownAssertion
expect(serializeProviderBuffer).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
@@ -488,11 +491,13 @@ describe('OrcaRuntimeService', () => {
).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: true,
readiness: { state: 'ready', agent: 'codex' },
status: 'running'
})
})
it('resolves tui-idle from an Antigravity ready prompt preview', async () => {
it('keeps an Antigravity ready prompt unsupported', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
@@ -508,6 +513,8 @@ describe('OrcaRuntimeService', () => {
).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: false,
readiness: { state: 'unsupported', agent: 'antigravity' },
status: 'running'
})
})
@@ -545,7 +552,8 @@ describe('OrcaRuntimeService', () => {
).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: true,
satisfied: false,
readiness: { state: 'unsupported', agent: 'antigravity' },
status: 'running'
})
const splitReadyTail = splitSpy.mock.contexts.some((context) => {
@@ -579,7 +587,8 @@ describe('OrcaRuntimeService', () => {
).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: true,
satisfied: false,
readiness: { state: 'unsupported', agent: 'antigravity' },
status: 'running'
})
})
@@ -55,7 +55,7 @@ describe('OrcaRuntimeService', () => {
})
})
it('resolves tui-idle when a stale Codex prompt is followed by Antigravity readiness', async () => {
it('keeps Antigravity readiness unsupported after a stale Codex prompt', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
@@ -80,7 +80,8 @@ describe('OrcaRuntimeService', () => {
).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: true,
satisfied: false,
readiness: { state: 'unsupported', agent: 'antigravity' },
status: 'running'
})
})
@@ -264,10 +265,13 @@ describe('OrcaRuntimeService', () => {
Date.now()
)
// Busy Cursor is neither blocked nor idle, so the wait times out honestly instead of returning a stale trust block.
// Busy Cursor is neither blocked nor idle, so the bounded wait remains explicitly unknown.
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 })
).rejects.toThrow('timeout')
).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
})
it('returns an agent-neutral blocked wait result for cwd selection prompts', async () => {
@@ -411,17 +415,20 @@ describe('OrcaRuntimeService', () => {
condition: 'tui-idle',
timeoutMs: 1_000
})
const timeoutAssertion = expect(waitPromise).rejects.toThrow('timeout')
const unknownAssertion = expect(waitPromise).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
await vi.advanceTimersByTimeAsync(2_000)
await timeoutAssertion
await unknownAssertion
} finally {
vi.useRealTimers()
}
})
it('resolves tui-idle for quiet background PTY agents without OSC titles', async () => {
it('keeps quiet background PTY agents without readiness evidence unknown', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
@@ -441,10 +448,12 @@ describe('OrcaRuntimeService', () => {
const waitAssertion = expect(waitPromise).resolves.toMatchObject({
handle,
condition: 'tui-idle',
satisfied: false,
readiness: { state: 'unknown' },
status: 'running'
})
await vi.advanceTimersByTimeAsync(6_000)
await vi.advanceTimersByTimeAsync(10_000)
await waitAssertion
} finally {
@@ -582,7 +582,7 @@ describe('OrcaRuntimeService', () => {
})
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 50 })
).rejects.toThrow('timeout')
).resolves.toMatchObject({ satisfied: false, readiness: { state: 'unknown' } })
const lateReadySnapshot = deferred<{
data: string
scrollbackAnsi: string
@@ -609,7 +609,10 @@ describe('OrcaRuntimeService', () => {
source: 'headless',
alternateScreen: false
})
await expect(staleReadyWait).rejects.toThrow('timeout')
await expect(staleReadyWait).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
serializeProviderBuffer.mockResolvedValueOnce({
data: 'Do you trust this workspace directory?\r\n1. Yes\r\n2. No\r\n',
scrollbackAnsi: '',
@@ -628,7 +631,7 @@ describe('OrcaRuntimeService', () => {
serializeProviderBuffer.mockImplementationOnce(() => new Promise(() => {}))
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 50 })
).rejects.toThrow('timeout')
).resolves.toMatchObject({ satisfied: false, readiness: { state: 'unknown' } })
expect(serializeProviderBuffer).toHaveBeenCalledTimes(6)
// Why args, not counts: the one-shot responses above ignore their options,
// so only this asserts every idle probe asked for the visible grid alone.
@@ -621,7 +621,7 @@ describe('OrcaRuntimeService', () => {
}
})
it('still auto-submits to a non-Cursor agent when its idle title mentions Cursor Agent', async () => {
it('does not auto-submit when only a title mentions Cursor Agent', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
@@ -645,11 +645,7 @@ describe('OrcaRuntimeService', () => {
runtime.deliverPendingMessagesForHandle(terminal.handle)
await vi.advanceTimersByTimeAsync(500)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
expect(write).toHaveBeenCalledWith('pty-1', '\r')
expect(write).not.toHaveBeenCalled()
db.close()
} finally {
vi.useRealTimers()
@@ -62,7 +62,16 @@ describe('orchestration RPC methods', () => {
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
bytesWritten: 1
bytesWritten: 1,
prompt: {
requestId: 'request-worker',
stages: ['input_accepted', 'turn_started'],
provider: 'codex',
observation: 'supported',
processIncarnation: 'runtime_test:term_worker:1',
generation: 1,
baselineWorkingSequence: 0
}
})
}
@@ -415,7 +424,7 @@ describe('orchestration RPC methods', () => {
expect(createWorktree).not.toHaveBeenCalled()
})
it('returns a failed receipt and preserves a created terminal as residual', async () => {
it('returns an unknown receipt and preserves a created terminal as residual', async () => {
setup()
mockCurrentWorkerStart({ ready: false })
const task = db.createTask({ spec: 'worker timeout' })
@@ -426,9 +435,9 @@ describe('orchestration RPC methods', () => {
agent: 'codex'
})) as { state: string; failedStage: string; residualResources: { id: string }[] }
expect(result).toMatchObject({ state: 'failed', failedStage: 'agent_readiness' })
expect(result).toMatchObject({ state: 'outcome_unknown', failedStage: 'agent_readiness' })
expect(result.residualResources).toEqual([expect.objectContaining({ id: 'term_worker' })])
expect(db.getTask(task.id)?.status).toBe('failed')
expect(db.getTask(task.id)?.status).toBe('blocked')
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
@@ -504,7 +513,7 @@ describe('orchestration RPC methods', () => {
})) as { state: string; failedStage: string; lastError: string }
expect(result).toMatchObject({
state: 'failed',
state: 'outcome_unknown',
failedStage: 'agent_readiness',
lastError: `Agent startup blocked: ${expectedReason}`
})
@@ -182,16 +182,23 @@ export async function startLocalWorker(args: {
if (wait) {
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
if (!wait.satisfied) {
if (setupReceipt.state === 'failed') {
failedStage = 'setup_wait'
const setupFailed =
setupReceipt.startupPolicy === 'wait-for-setup' &&
(setupReceipt.state === 'failed' || wait.status === 'exited')
const knownAgentExit =
setupReceipt.startupPolicy !== 'wait-for-setup' && wait.status === 'exited'
if (setupFailed || knownAgentExit) {
if (setupFailed) {
failedStage = 'setup_wait'
}
throw new Error(
wait.blockedReason
? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}`
: structuredSession
? `Setup did not finish before the structured worker started (${wait.status}).`
: `Agent did not become ready (${wait.status}).`
)
}
throw new Error(
wait.blockedReason
? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}`
: structuredSession
? `Setup did not finish before the structured worker started (${wait.status}).`
: `Agent did not become ready (${wait.status}).`
)
}
}
const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle)
@@ -205,6 +212,29 @@ export async function startLocalWorker(args: {
terminalOwnership: params.terminal ? 'external' : 'created'
})
if (wait && !wait.satisfied) {
// The terminal and dispatch authority already exist. Preserve both when startup readiness
// is unsupported or unobserved; no prompt bytes have been written yet, so no retry is safe.
const readinessState = 'readiness' in wait ? wait.readiness?.state : undefined
const reason = wait.blockedReason
? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}`
: readinessState === 'unsupported'
? `Agent startup readiness is unsupported for ${agent ?? 'this terminal'}; no prompt was submitted.`
: `Agent startup readiness could not be verified (${wait.status}); no prompt was submitted.`
return failWorkerStartWithReceipt({
db,
runId: run.id,
taskId: task.id,
dispatchId: started.dispatch.id,
failedStage: 'agent_readiness',
error: Object.assign(new Error(reason), { code: 'operation_unknown' }),
setup: setupReceipt,
launch: launch.receipt,
mode,
terminalHandle
})
}
return await deliverAndSettleWorkerStartReadiness({
runtime,
db,
@@ -244,7 +274,8 @@ export async function startLocalWorker(args: {
error,
setup: placed?.setupReceipt ?? EXISTING_WORKTREE_SETUP,
launch: launch.receipt,
mode
mode,
terminalHandle
})
}
}
@@ -67,7 +67,16 @@ describe('orchestration worker release recovery', () => {
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
bytesWritten: 1
bytesWritten: 1,
prompt: {
requestId: 'request-worker',
stages: ['input_accepted', 'turn_started'],
provider: 'codex',
observation: 'supported',
processIncarnation: 'runtime_test:term_worker:1',
generation: 1,
baselineWorkingSequence: 0
}
})
vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true)
vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null)
@@ -96,7 +96,16 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
bytesWritten: 1
bytesWritten: 1,
prompt: {
requestId: 'request-worker',
stages: ['input_accepted', 'turn_started'],
provider: 'codex',
observation: 'supported',
processIncarnation: 'runtime_test:term_worker:1',
generation: 1,
baselineWorkingSequence: 0
}
})
vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true)
vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null)
@@ -6,6 +6,7 @@ import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preambl
import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences'
import {
describeUnobservedWorkerTurnStart,
describeUnsupportedWorkerTurnStart,
observeWorkerTurnStart,
type WorkerTurnStartObservation
} from './worker-start-turn-observation'
@@ -18,8 +19,8 @@ import {
/**
* Delivers the dispatch preamble and settles the worker's start state on the strongest
* evidence available: `ready` only with a positive turn-start (or a provider that cannot
* prove one), `start_unknown` when observation is supported and nothing started.
* evidence available: `ready` only with a positive turn-start, and `start_unknown` when
* turn-start observation is unsupported or remains unobserved.
*/
export async function deliverAndSettleWorkerStartReadiness(args: {
runtime: OrcaRuntimeService
@@ -88,20 +89,26 @@ export async function deliverAndSettleWorkerStartReadiness(args: {
// A worker report can settle the dispatch while turn observation is outstanding.
const currentWorker = db.getWorkerDispatch(args.dispatchId)
const alreadySettled = currentWorker && currentWorker.state !== 'starting'
if (turnStart.verdict === 'unobserved' && !alreadySettled) {
if (
(turnStart.verdict === 'unobserved' || turnStart.verdict === 'unsupported') &&
!alreadySettled
) {
// Honest `unverifiable`: keep the dispatch capability and the terminal — the worker may
// still recover and report (worker-report settlement reconnects a start_unknown worker) —
// but never claim ready for a turn nobody observed.
const turnUnknown = turnStart.verdict === 'unobserved'
effects.push({
kind: 'dispatch_input',
role: 'agent',
id: terminalHandle,
state: 'turn_unobserved'
state: turnUnknown ? 'turn_unobserved' : 'turn_start_unsupported'
})
const reason = describeUnobservedWorkerTurnStart(args.agent)
const reason = turnUnknown
? describeUnobservedWorkerTurnStart(args.agent)
: describeUnsupportedWorkerTurnStart(args.agent)
const worker = db.markWorkerStartUnknown(
args.dispatchId,
'turn_start_unobserved',
turnUnknown ? 'turn_start_unobserved' : 'turn_start_unsupported',
reason,
effects
)
@@ -17,6 +17,7 @@ export function failWorkerStartWithReceipt(args: {
setup: WorkerSetupReceipt
launch: OrchestrationWorkerLaunchReceipt
mode: WorkerStartModeReceipt
terminalHandle?: string
}): unknown {
const agentSessionRefusal = isAgentSessionPtyWriteRefusedError(args.error)
? args.error.refusal
@@ -62,6 +63,9 @@ export function failWorkerStartWithReceipt(args: {
? {
nextCommands: [
`orca orchestration worker-show --dispatch ${args.dispatchId} --json`,
...(args.terminalHandle
? [`orca terminal read --terminal ${args.terminalHandle} --screen`]
: []),
`orca orchestration worker-abandon --dispatch ${args.dispatchId} --json`
]
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-contracts'
import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-prompt-delivery'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
import { observeWorkerTurnStart } from './worker-start-turn-observation'
@@ -1,5 +1,5 @@
import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../../../../../shared/orchestration-timing-budgets'
import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-contracts'
import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-prompt-delivery'
import type { OrcaRuntimeService } from '../../../../orca-runtime'
/**
@@ -84,3 +84,12 @@ export function describeUnobservedWorkerTurnStart(agent: string | null): string
'reports, this Dispatch settles normally.'
)
}
export function describeUnsupportedWorkerTurnStart(agent: string | null): string {
const name = agent ?? 'the agent'
return (
`Dispatch input was accepted by ${name}, but this launch path has no correlated turn-start ` +
'observation. The prompt was submitted once; its turn outcome is unknown and no automatic ' +
'resend is safe. Inspect the terminal or let the worker report before abandoning it.'
)
}
@@ -70,7 +70,16 @@ describe('orchestration new-worktree workers', () => {
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({
handle: 'term_worker',
accepted: true,
bytesWritten: 1
bytesWritten: 1,
prompt: {
requestId: 'request-worker',
stages: ['input_accepted', 'turn_started'],
provider: 'codex',
observation: 'supported',
processIncarnation: 'runtime_test:term_worker:1',
generation: 1,
baselineWorkingSequence: 0
}
})
})
@@ -445,13 +454,14 @@ describe('orchestration new-worktree workers', () => {
exitCode: null
})
const { result } = await startWorker()
const { result, task } = await startWorker()
expect(result).toMatchObject({
state: 'failed',
state: 'outcome_unknown',
failedStage: 'agent_readiness',
setup: { state: 'running' }
})
expect(db.getTask(task.id)?.status).toBe('blocked')
expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
@@ -729,7 +739,20 @@ describe('orchestration new-worktree workers', () => {
})
)
finishPrompt?.({ handle: 'term_worker', accepted: true, bytesWritten: 1 })
finishPrompt?.({
handle: 'term_worker',
accepted: true,
bytesWritten: 1,
prompt: {
requestId: 'request-worker',
stages: ['input_accepted', 'turn_started'],
provider: 'codex',
observation: 'supported',
processIncarnation: 'runtime_test:term_worker:1',
generation: 1,
baselineWorkingSequence: 0
}
})
await expect(pending).resolves.toMatchObject({
result: { state: 'ready', stage: 'input_accepted' }
})
@@ -158,6 +158,8 @@ export type OrchestrationCompatibilitySshAttachmentAuthority = Extract<
export type TerminalWaiter = {
handle: string
/** PTY/process incarnation captured when the wait was registered. */
processIncarnation: string | null
condition: RuntimeTerminalWaitCondition
resolve: (result: RuntimeTerminalWait) => void
reject: (error: Error) => void
@@ -41,6 +41,7 @@ function makeLeaf(tabId: string, overrides: Partial<RuntimeLeafRecord> = {}) {
function makeWaiter(handle: string): TerminalWaiter {
return {
handle,
processIncarnation: null,
condition: 'tui-idle',
resolve: () => {},
reject: () => {},
@@ -67,9 +68,7 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
const resolved: { handle: string; result: RuntimeTerminalWait }[] = []
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () => null,
getAdoptedPtyIdleStatus: () => null,
getPaneAgent: () => null,
getFirstPartyAgentStatus: () => null,
@@ -81,7 +80,10 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
const waiter = makeWaiter(`handle-${index}`)
// Already idle: an independent interval would have resolved this on its own
// first tick at exactly intervalMs, and so must the shared sweep.
polls.startPty(waiter, makePty(`pty-${index}`, { lastAgentStatus: 'idle' }))
polls.startPty(
waiter,
makePty(`pty-${index}`, { lastAgentStatus: 'idle', lastOscTitle: 'Codex ready' })
)
return waiter
})
@@ -102,9 +104,7 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
it('keeps one interval across mixed leaf and pty waiters and re-arms after going idle', () => {
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () => null,
getAdoptedPtyIdleStatus: () => null,
getPaneAgent: () => null,
getFirstPartyAgentStatus: () => null,
@@ -127,9 +127,7 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
it('retires the shared timer when the last waiter is cancelled through the waiter record', () => {
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () => null,
getAdoptedPtyIdleStatus: () => null,
getPaneAgent: () => null,
getFirstPartyAgentStatus: () => null,
@@ -149,17 +147,11 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
expect(polls.activeTimerCount).toBe(0)
})
it('runs the foreground read per waiter without one waiter blocking another', async () => {
it('does not infer readiness from a quiet foreground process', async () => {
const resolved: string[] = []
const gates: ((value: string | null) => void)[] = []
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () =>
new Promise<string | null>((resolve) => {
gates.push(resolve)
}),
getAdoptedPtyIdleStatus: () => null,
getPaneAgent: () => null,
getFirstPartyAgentStatus: () => null,
@@ -167,21 +159,11 @@ describe('RuntimeTerminalIdlePolls timer budget', () => {
resolve: (waiter) => resolved.push(waiter.handle)
})
polls.startPty(makeWaiter('slow'), makePty('pty-slow', { lastOutputAt: Date.now() - 10_000 }))
polls.startPty(makeWaiter('fast'), makePty('pty-fast', { lastOutputAt: Date.now() - 10_000 }))
polls.startPty(makeWaiter('quiet'), makePty('pty-quiet', { lastOutputAt: Date.now() - 10_000 }))
vi.advanceTimersByTime(INTERVAL_MS)
// Both waiters issued their read in the same sweep — a sequential sweep would
// have blocked the second behind the first's unresolved promise.
expect(gates).toHaveLength(2)
gates[1]('node')
await vi.advanceTimersByTimeAsync(0)
expect(resolved).toEqual(['fast'])
gates[0]('node')
await vi.advanceTimersByTimeAsync(0)
expect(resolved).toEqual(['fast', 'slow'])
expect(polls.activeTimerCount).toBe(0)
expect(resolved).toEqual([])
expect(polls.activeTimerCount).toBe(1)
})
})
+47 -107
View File
@@ -1,8 +1,8 @@
import { isShellProcess, type AgentStatus } from '../../shared/agent-detection'
import type { AgentStatus } from '../../shared/agent-detection'
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptPreview
detectKnownReadyPromptAgent
} from './terminal-wait-detection'
import {
buildPtyTerminalWaitBlockedResult,
@@ -11,34 +11,17 @@ import {
buildTerminalWaitResult
} from './terminal-wait-results'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import {
isTuiIdleSatisfied,
quietForegroundProcessProvesTuiIdle,
type FirstPartyAgentStatus
} from './tui-idle-evidence'
import { observeTuiIdle, type FirstPartyAgentStatus } from './tui-idle-evidence'
import type { TuiAgent } from '../../shared/tui-agent'
/**
* Why null counts as quiet: a record with no output timestamp has produced nothing the
* RUNTIME OBSERVED since it was created. That is not the same as silence — the reachable
* case is a daemon-hosted pane whose bytes never reach the runtime, which may still be
* streaming. The trade is deliberate: "never settles" becomes "settles uncorroborated",
* the caller keeps its timeout, and delivery cannot reach this lane. Reading it as `0ms since output`
* inverted that — `0 >= quiescenceMs` is false forever, so an adopted pane that never
* emitted could not settle no matter how long the caller waited.
*/
function isQuietForQuiescence(lastOutputAt: number | null, quiescenceMs: number): boolean {
return lastOutputAt === null ? true : Date.now() - lastOutputAt >= quiescenceMs
}
import type { TerminalWaiter } from './runtime-terminal-contracts'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
type RuntimeTerminalIdlePollDependencies = {
intervalMs: number
quiescenceMs: number
getTabTitle(tabId: string): string | null
getForegroundProcess(ptyId: string): Promise<string | null> | null
getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null
getAdoptedPtyTitle?(pty: RuntimePtyWorktreeRecord): string | null
getPaneAgent(ptyId: string | null | undefined): TuiAgent | null
getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus
/** Re-read the record the waiter registered against; see `liveLeaf` below. */
@@ -51,13 +34,11 @@ type IdlePollEntry =
kind: 'leaf'
waiter: TerminalWaiter
leaf: RuntimeLeafRecord
foregroundPollInFlight: boolean
}
| {
kind: 'pty'
waiter: TerminalWaiter
pty: RuntimePtyWorktreeRecord
foregroundPollInFlight: boolean
}
export class RuntimeTerminalIdlePolls {
@@ -67,11 +48,11 @@ export class RuntimeTerminalIdlePolls {
constructor(private readonly deps: RuntimeTerminalIdlePollDependencies) {}
startLeaf(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void {
this.start({ kind: 'leaf', waiter, leaf, foregroundPollInFlight: false })
this.start({ kind: 'leaf', waiter, leaf })
}
startPty(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void {
this.start({ kind: 'pty', waiter, pty, foregroundPollInFlight: false })
this.start({ kind: 'pty', waiter, pty })
}
/** Test/diagnostic seam: live sweep handles, which must stay at most one. */
@@ -106,14 +87,13 @@ export class RuntimeTerminalIdlePolls {
}
const { waiter } = entry
// Why re-read: `syncWindowGraph` rebuilds `this.leaves` with fresh objects on every
// renderer publish, so the record captured at registration stops advancing. Its
// `lastOutputAt` freezes, the quiescence gate below then reads an ever-growing
// elapsed time, and the waiter settles while the pane is in fact still streaming.
// 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.
const leaf = this.deps.getLiveLeaf(entry.leaf)
const agent = this.deps.getPaneAgent(leaf.ptyId)
let startedForegroundPoll = false
try {
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const promptAgent = detectKnownReadyPromptAgent(waitText)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(entry)
@@ -123,49 +103,27 @@ export class RuntimeTerminalIdlePolls {
)
return
}
if (
isTuiIdleSatisfied({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText),
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
) {
const observation = observeTuiIdle({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () => promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId)
})
if (observation.state === 'ready') {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
if (
leaf.lastAgentStatus === null &&
quietForegroundProcessProvesTuiIdle(agent) &&
leaf.ptyId &&
!entry.foregroundPollInFlight
) {
const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId)
if (!foregroundRead) {
return
}
entry.foregroundPollInFlight = true
startedForegroundPoll = true
const foreground = await foregroundRead
const live = this.deps.getLiveLeaf(entry.leaf)
if (
foreground &&
!isShellProcess(foreground) &&
isQuietForQuiescence(live.lastOutputAt, this.deps.quiescenceMs)
) {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live))
}
this.deps.resolve(
waiter,
buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf, {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
})
)
}
} catch {
// Transient process inspection errors do not retire the waiter.
} finally {
if (startedForegroundPoll) {
entry.foregroundPollInFlight = false
}
}
}
@@ -177,9 +135,9 @@ export class RuntimeTerminalIdlePolls {
// 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)
let startedForegroundPoll = false
try {
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
const promptAgent = detectKnownReadyPromptAgent(waitText)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(entry)
@@ -189,48 +147,30 @@ export class RuntimeTerminalIdlePolls {
)
return
}
if (
isTuiIdleSatisfied({
record: pty,
readPositiveBodyEvidence: () =>
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
isKnownReadyPromptPreview(waitText),
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
) {
const adoptedIdle = this.deps.getAdoptedPtyIdleStatus(pty) === 'idle'
const adoptedTitle = this.deps.getAdoptedPtyTitle?.(pty) ?? null
const observation = observeTuiIdle({
record: pty,
rendererTitle: adoptedTitle,
readPositiveBodyEvidence: () => adoptedIdle || promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
positiveBodyEvidenceSource: adoptedIdle ? 'title' : 'screen',
agent,
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId)
})
if (observation.state === 'ready') {
this.stop(entry)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
return
}
if (
pty.lastAgentStatus === null &&
quietForegroundProcessProvesTuiIdle(agent) &&
!entry.foregroundPollInFlight
) {
const foregroundRead = this.deps.getForegroundProcess(pty.ptyId)
if (!foregroundRead) {
return
}
entry.foregroundPollInFlight = true
startedForegroundPoll = true
const foreground = await foregroundRead
if (
foreground &&
!isShellProcess(foreground) &&
isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs)
) {
this.stop(entry)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
}
this.deps.resolve(
waiter,
buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty, {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
})
)
}
} catch {
// Transient process inspection errors do not retire the waiter.
} finally {
if (startedForegroundPoll) {
entry.foregroundPollInFlight = false
}
}
}
@@ -0,0 +1,65 @@
import type { AgentStatus } from '../../shared/agent-detection'
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 {
observeTuiIdle,
type FirstPartyAgentStatus,
type TuiIdleObservation
} from './tui-idle-evidence'
type RuntimeTerminalWaitEvidenceDependencies = {
getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null
getAdoptedPtyTitle?(pty: RuntimePtyWorktreeRecord): string | null
getTabTitle(tabId: string): string | null
getPaneAgent(ptyId: string | null | undefined): TuiAgent | null
getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus
}
export class RuntimeTerminalWaitEvidence {
constructor(private readonly deps: RuntimeTerminalWaitEvidenceDependencies) {}
result(observation: TuiIdleObservation): RuntimeTerminalReadiness {
return {
state: observation.state,
source: observation.source,
...(observation.agent ? { agent: observation.agent } : {})
}
}
observePty(pty: RuntimePtyWorktreeRecord, waitText: string): TuiIdleObservation {
const promptAgent = detectKnownReadyPromptAgent(waitText)
const adoptedIdle = this.deps.getAdoptedPtyIdleStatus(pty) === 'idle'
const adoptedTitle = this.deps.getAdoptedPtyTitle?.(pty) ?? null
return observeTuiIdle({
record: pty,
rendererTitle: adoptedTitle,
readPositiveBodyEvidence: () => adoptedIdle || promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
positiveBodyEvidenceSource: adoptedIdle ? 'title' : 'screen',
agent: this.deps.getPaneAgent(pty.ptyId),
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId)
})
}
observeLeaf(leaf: RuntimeLeafRecord, waitText: string): TuiIdleObservation {
const promptAgent = detectKnownReadyPromptAgent(waitText)
return observeTuiIdle({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () => promptAgent !== null,
positiveBodyEvidenceAgent: promptAgent,
agent: this.deps.getPaneAgent(leaf.ptyId),
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId)
})
}
isPtySatisfied(pty: RuntimePtyWorktreeRecord, waitText: string): boolean {
return this.observePty(pty, waitText).state === 'ready'
}
isLeafSatisfied(leaf: RuntimeLeafRecord, waitText: string): boolean {
return this.observeLeaf(leaf, waitText).state === 'ready'
}
}
@@ -0,0 +1,67 @@
import type { RuntimeTerminalWait as RuntimeTerminalWaitResult } from '../../shared/runtime-types'
import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal-wait-results'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import type { RuntimeTerminalWaitEvidence } from './runtime-terminal-wait-evidence'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
type RuntimeTerminalWaitTimeoutDependencies = {
getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null
getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord }
}
export function resolvePtyTuiIdleTimeout(
handle: string,
resolve: (result: RuntimeTerminalWaitResult) => void,
reject: (error: Error) => void,
deps: RuntimeTerminalWaitTimeoutDependencies,
evidence: RuntimeTerminalWaitEvidence
): void {
const live = deps.getLivePty(handle)
if (!live) {
reject(new Error('terminal_handle_stale'))
return
}
const current = live.pty
const currentText = buildTerminalWaitText(
current.tailBuffer,
current.tailPartialLine,
current.preview
)
resolve(
buildPtyTerminalWaitResult(
handle,
'tui-idle',
current,
evidence.result(evidence.observePty(current, currentText))
)
)
}
export function resolveLeafTuiIdleTimeout(
handle: string,
resolve: (result: RuntimeTerminalWaitResult) => void,
reject: (error: Error) => void,
deps: RuntimeTerminalWaitTimeoutDependencies,
evidence: RuntimeTerminalWaitEvidence
): void {
let current: RuntimeLeafRecord
try {
current = deps.getLiveLeaf(handle).leaf
} catch {
reject(new Error('terminal_handle_stale'))
return
}
const currentText = buildTerminalWaitText(
current.tailBuffer,
current.tailPartialLine,
current.preview
)
resolve(
buildTerminalWaitResult(
handle,
'tui-idle',
current,
evidence.result(evidence.observeLeaf(current, currentText))
)
)
}
+59 -50
View File
@@ -2,10 +2,7 @@ import type {
RuntimeTerminalWait as RuntimeTerminalWaitResult,
RuntimeTerminalWaitCondition
} from '../../shared/runtime-types'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptPreview
} from './terminal-wait-detection'
import { detectTerminalWaitBlockedReason } from './terminal-wait-detection'
import {
buildPtyTerminalWaitBlockedResult,
buildPtyTerminalWaitResult,
@@ -14,12 +11,17 @@ import {
getTerminalState
} from './terminal-wait-results'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import { isTuiIdleSatisfied, type FirstPartyAgentStatus } from './tui-idle-evidence'
import type { FirstPartyAgentStatus } from './tui-idle-evidence'
import type { TuiAgent } from '../../shared/tui-agent'
import type { TerminalWaiter } from './runtime-terminal-contracts'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { AgentStatus } from '../../shared/agent-detection'
import type { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
import { RuntimeTerminalWaitEvidence } from './runtime-terminal-wait-evidence'
import {
resolveLeafTuiIdleTimeout,
resolvePtyTuiIdleTimeout
} from './runtime-terminal-wait-timeouts'
import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
type RuntimeTerminalWaitDependencies = {
@@ -27,42 +29,23 @@ type RuntimeTerminalWaitDependencies = {
getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null
getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord }
getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null
getAdoptedPtyTitle?(pty: RuntimePtyWorktreeRecord): string | null
getTabTitle(tabId: string): string | null
quiescenceMs: number
getPaneAgent(ptyId: string | null | undefined): TuiAgent | null
getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus
getTerminalProcessIncarnation(handle: string): string | null
startVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void
}
export class RuntimeTerminalWait {
private readonly evidence: RuntimeTerminalWaitEvidence
constructor(
private readonly deps: RuntimeTerminalWaitDependencies,
private readonly waiters: RuntimeTerminalWaiterRegistry,
private readonly polls: RuntimeTerminalIdlePolls
) {}
/** Why one helper per record kind: every satisfaction site must rank the same way,
* or the immediate check and the poll disagree about the same pane. */
private ptySatisfied(pty: RuntimePtyWorktreeRecord, waitText: string): boolean {
return isTuiIdleSatisfied({
record: pty,
readPositiveBodyEvidence: () =>
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText),
agent: this.deps.getPaneAgent(pty.ptyId),
firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
}
private leafSatisfied(leaf: RuntimeLeafRecord, waitText: string): boolean {
return isTuiIdleSatisfied({
record: leaf,
rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText),
agent: this.deps.getPaneAgent(leaf.ptyId),
firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId),
quiescenceMs: this.deps.quiescenceMs
})
) {
this.evidence = new RuntimeTerminalWaitEvidence(deps)
}
async wait(
@@ -88,8 +71,13 @@ export class RuntimeTerminalWait {
if (condition === 'tui-idle' && ptyBlockedReason) {
return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason)
}
if (condition === 'tui-idle' && this.ptySatisfied(pty.pty, ptyWaitText)) {
return buildPtyTerminalWaitResult(handle, condition, pty.pty)
if (condition === 'tui-idle' && this.evidence.isPtySatisfied(pty.pty, ptyWaitText)) {
return buildPtyTerminalWaitResult(
handle,
condition,
pty.pty,
this.evidence.result(this.evidence.observePty(pty.pty, ptyWaitText))
)
}
return await new Promise<RuntimeTerminalWaitResult>((resolve, reject) => {
const effectiveTimeoutMs =
@@ -100,6 +88,7 @@ export class RuntimeTerminalWait {
: 0
const waiter: TerminalWaiter = {
handle,
processIncarnation: this.deps.getTerminalProcessIncarnation(handle),
condition,
resolve,
reject,
@@ -114,7 +103,11 @@ export class RuntimeTerminalWait {
if (effectiveTimeoutMs > 0) {
waiter.timeout = setTimeout(() => {
this.waiters.remove(waiter)
reject(new Error('timeout'))
if (condition !== 'tui-idle') {
reject(new Error('timeout'))
return
}
resolvePtyTuiIdleTimeout(handle, resolve, reject, this.deps, this.evidence)
}, effectiveTimeoutMs)
}
this.waiters.add(waiter)
@@ -136,8 +129,16 @@ export class RuntimeTerminalWait {
waiter,
buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason)
)
} else if (this.ptySatisfied(live.pty, livePtyWaitText)) {
this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty))
} else if (this.evidence.isPtySatisfied(live.pty, livePtyWaitText)) {
this.waiters.resolve(
waiter,
buildPtyTerminalWaitResult(
handle,
condition,
live.pty,
this.evidence.result(this.evidence.observePty(live.pty, livePtyWaitText))
)
)
} else {
this.polls.startPty(waiter, live.pty)
if (live.pty.lastAgentStatus === null && livePtyWaitText.length === 0) {
@@ -163,8 +164,13 @@ export class RuntimeTerminalWait {
// detection that powers the renderer's "Task complete" notifications.
// Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the
// agent is blocked on user approval, not finished with its task.
if (condition === 'tui-idle' && this.leafSatisfied(leaf, leafWaitText)) {
return buildTerminalWaitResult(handle, condition, leaf)
if (condition === 'tui-idle' && this.evidence.isLeafSatisfied(leaf, leafWaitText)) {
return buildTerminalWaitResult(
handle,
condition,
leaf,
this.evidence.result(this.evidence.observeLeaf(leaf, leafWaitText))
)
}
return await new Promise<RuntimeTerminalWaitResult>((resolve, reject) => {
@@ -180,6 +186,7 @@ export class RuntimeTerminalWait {
const waiter: TerminalWaiter = {
handle,
processIncarnation: this.deps.getTerminalProcessIncarnation(handle),
condition,
resolve,
reject,
@@ -196,15 +203,16 @@ export class RuntimeTerminalWait {
if (effectiveTimeoutMs > 0) {
waiter.timeout = setTimeout(() => {
this.waiters.remove(waiter)
reject(new Error('timeout'))
if (condition !== 'tui-idle') {
reject(new Error('timeout'))
return
}
resolveLeafTuiIdleTimeout(handle, resolve, reject, this.deps, this.evidence)
}, effectiveTimeoutMs)
}
this.waiters.add(waiter)
// Why: the handle may go stale or exit in the small gap between the first
// validation and waiter registration. Re-checking here keeps wait --for
// exit honest instead of hanging on a terminal that already changed.
try {
const live = this.deps.getLiveLeaf(handle)
if (getTerminalState(live.leaf) === 'exited') {
@@ -221,16 +229,17 @@ export class RuntimeTerminalWait {
waiter,
buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason)
)
} else if (this.leafSatisfied(live.leaf, liveLeafWaitText)) {
// Why: don't clear lastAgentStatus here. It's a factual record of the
// last detected OSC state, not a one-shot signal. Clearing it causes
// subsequent tui-idle waiters to hang even though the agent is idle —
// the first waiter consumes the status and all later ones see null.
this.waiters.resolve(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
} else if (this.evidence.isLeafSatisfied(live.leaf, liveLeafWaitText)) {
this.waiters.resolve(
waiter,
buildTerminalWaitResult(
handle,
condition,
live.leaf,
this.evidence.result(this.evidence.observeLeaf(live.leaf, liveLeafWaitText))
)
)
} else {
// Why: renderer-synced previews can show a known ready prompt even
// while the last OSC title is still "working"; keep polling the
// preview/title until the waiter resolves or hits its timeout.
this.polls.startLeaf(waiter, live.leaf)
if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) {
this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs)
+25 -15
View File
@@ -45,16 +45,35 @@ export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus |
memoizeTitleClassification(computeExplicitIdleStatusFromTitle)
export function isKnownReadyPromptPreview(preview: string): boolean {
return detectKnownReadyPromptAgent(preview) !== null
}
export type KnownReadyPromptAgent = 'codex' | 'cursor' | 'antigravity'
/**
* Identifies the provider behind a positive prompt preview instead of returning a bare boolean.
* Callers use this identity to keep screen evidence attached to the provider that rendered it.
*/
export function detectKnownReadyPromptAgent(preview: string): KnownReadyPromptAgent | null {
const normalized = preview.toLowerCase()
const readyIndex = findKnownReadyPromptIndex(normalized)
if (readyIndex === null) {
return false
const ready = [
{ agent: 'codex' as const, index: findCodexReadyPromptIndex(normalized) },
{ agent: 'cursor' as const, index: findCursorReadyPromptIndex(normalized) },
{ agent: 'antigravity' as const, index: findAntigravityReadyPromptIndex(normalized) }
]
.filter(
(candidate): candidate is { agent: KnownReadyPromptAgent; index: number } =>
candidate.index !== null
)
.sort((left, right) => right.index - left.index)[0]
if (!ready) {
return null
}
const blockedSignal = findTerminalWaitBlockedSignal(normalized)
if (blockedSignal !== null && blockedSignal.index > readyIndex) {
return false
if (blockedSignal !== null && blockedSignal.index > ready.index) {
return null
}
return true
return ready.agent
}
export function detectTerminalWaitBlockedReason(
@@ -88,15 +107,6 @@ function findDismissedStartupModalIndex(normalized: string): number | null {
return indexes.length > 0 ? Math.max(...indexes) : null
}
function findKnownReadyPromptIndex(normalized: string): number | null {
const indexes = [
findCodexReadyPromptIndex(normalized),
findAntigravityReadyPromptIndex(normalized),
findCursorReadyPromptIndex(normalized)
].filter((index): index is number => index !== null)
return indexes.length > 0 ? Math.max(...indexes) : null
}
// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt.
function findCursorActivePromptIndex(normalized: string): number | null {
const headerIndex = normalized.lastIndexOf('cursor agent')
@@ -16,11 +16,11 @@ import type { FirstPartyAgentStatus } from './tui-idle-evidence'
// #6011: `terminal wait --for tui-idle` returned satisfied in ~0s against a working agent,
// because a Codex/Devin OSC title that carries only the agent NAME is stored as `idle` and
// the wait accepted the stored value. These tests pin which evidence settles the wait,
// which only corroborates, and which vetoes.
// the wait accepted the stored value. These tests pin which attachment-bound evidence settles
// the wait, which remains unknown, and which vetoes.
const POLL_INTERVAL_MS = 2000
const QUIESCENCE_MS = 3000
const OUTPUT_AGE_FIXTURE_MS = 3000
const NAME_ONLY_TITLE = 'Codex'
const EXPLICIT_IDLE_TITLE = 'Codex ready'
const HANDLE = 'terminal-1'
@@ -29,6 +29,7 @@ function createWait(options: {
pty?: RuntimePtyWorktreeRecord
leaf?: RuntimeLeafRecord
adoptedIdleStatus?: AgentStatus | null
adoptedTitle?: string | null
tabTitle?: string | null
foreground?: string | null
agent?: TuiAgent | null
@@ -40,14 +41,14 @@ function createWait(options: {
const shared = {
getTabTitle: () => options.tabTitle ?? null,
getAdoptedPtyIdleStatus: () => options.adoptedIdleStatus ?? null,
getAdoptedPtyTitle: () => options.adoptedTitle ?? null,
getPaneAgent: () => options.agent ?? null,
getFirstPartyAgentStatus: () => options.firstPartyStatus ?? null,
quiescenceMs: QUIESCENCE_MS
getTerminalProcessIncarnation: () => 'test-incarnation'
}
const polls = new RuntimeTerminalIdlePolls({
...shared,
intervalMs: POLL_INTERVAL_MS,
getForegroundProcess: () => Promise.resolve(options.foreground ?? null),
getLiveLeaf: (leaf) => options.liveLeaf?.() ?? leaf,
resolve: (waiter, result) => waiters.resolve(waiter, result)
})
@@ -74,7 +75,7 @@ function watch(promise: Promise<unknown>) {
return settled
}
/** Keeps the record "streaming": output stays younger than the quiescence window. */
/** Keeps the record "streaming": each poll sees a fresh output timestamp. */
async function advanceWhileStreaming(
record: { lastOutputAt: number | null },
ticks: number
@@ -102,18 +103,21 @@ describe('tui-idle evidence ranking', () => {
expect(settled).not.toHaveBeenCalled()
})
it('settles a name-only idle once the pane has been quiet for the window', async () => {
it('remains unknown after a name-only pane has been quiet for the window', async () => {
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
const { wait } = createWait({ pty, agent: 'codex' })
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 10_000 })
await advanceWhileStreaming(pty, 2)
expect(settled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + POLL_INTERVAL_MS)
expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) })
// Still inside the timeout while output is arriving.
await vi.advanceTimersByTimeAsync(6_000)
await expect(result).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
})
it('settles an explicit idle title immediately, with no quiescence at all', async () => {
it('settles an explicit idle title immediately, without an elapsed-silence delay', async () => {
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: EXPLICIT_IDLE_TITLE })
const { wait } = createWait({ pty, agent: 'codex' })
await expect(
@@ -121,9 +125,50 @@ describe('tui-idle evidence ranking', () => {
).resolves.toMatchObject({ satisfied: true })
})
// Why this case exists: tier 1 used to read only the renderer-synced pane title, so a
// daemon-hosted pane with no renderer dropped its explicit `Codex ready` to the
// quiescence lane and waited the whole window for a result it already had.
it('accepts a provider-specific ready screen when launch metadata is absent', async () => {
const pty = makeTuiIdlePty({
preview: 'OpenAI Codex\nModel: gpt-5\nDirectory: /tmp/repo'
})
const { wait } = createWait({ pty, agent: null })
await expect(
wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })
).resolves.toMatchObject({
satisfied: true,
readiness: { state: 'ready', source: 'screen', agent: 'codex' }
})
})
it('accepts an adopted provider title when PTY launch metadata is absent', async () => {
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle' })
const { wait } = createWait({
pty,
agent: null,
adoptedIdleStatus: 'idle',
adoptedTitle: 'OMP ready'
})
await expect(
wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })
).resolves.toMatchObject({
satisfied: true,
readiness: { state: 'ready', source: 'title', agent: 'omp' }
})
})
it('does not attach a ready screen from a different provider to the launch', async () => {
const pty = makeTuiIdlePty({
preview: 'OpenAI Codex\nModel: gpt-5\nDirectory: /tmp/repo'
})
const { wait } = createWait({ pty, agent: 'claude' })
const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 100 })
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown', agent: 'claude' }
})
})
// Why this case exists: daemon-hosted panes may have no renderer title, but their retained
// attachment record still carries an explicit provider marker.
it('reads an explicit idle title off the record when no renderer published one', async () => {
const leaf = makeTuiIdleLeaf({
lastAgentStatus: 'idle',
@@ -140,7 +185,7 @@ describe('tui-idle evidence ranking', () => {
const pty = makeTuiIdlePty({
lastAgentStatus: 'idle',
lastOscTitle: NAME_ONLY_TITLE,
lastOutputAt: Date.now() - QUIESCENCE_MS * 4
lastOutputAt: Date.now() - OUTPUT_AGE_FIXTURE_MS * 4
})
const { wait } = createWait({
pty,
@@ -152,16 +197,15 @@ describe('tui-idle evidence ranking', () => {
expect(settled).not.toHaveBeenCalled()
})
// Why the scoping: demoting every name-only title left agents that emit their NAME and
// nothing else at rest with no settle signal at all. A real idle Grok pane repaints its
// banner about four times a second forever, so output never quiesces and the wait ran to
// timeout — a total loss of tui-idle for that provider.
it('settles immediately for an agent that never emits anything but its name', async () => {
it('returns unknown for an agent that never emits anything but its name', async () => {
const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: 'grok' })
const { wait } = createWait({ pty, agent: 'grok' })
await expect(
wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })
).resolves.toMatchObject({ satisfied: true })
const result = wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 100 })
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unsupported' }
})
})
it('falls back to the title when the pane carries no launch metadata', async () => {
@@ -172,9 +216,8 @@ describe('tui-idle evidence ranking', () => {
expect(settled).not.toHaveBeenCalled()
})
// Why: `syncWindowGraph` rebuilds leaf records, so a poll that keeps reading the record it
// captured sees a frozen `lastOutputAt`, and its quiescence gate passes while the real pane
// is still streaming.
// Why: `syncWindowGraph` rebuilds leaf records, so a poll must re-read the live attachment
// rather than a stale object from waiter registration.
it('tracks the live leaf record across a graph sync instead of a frozen capture', async () => {
const registered = makeTuiIdleLeaf({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
let live = registered
@@ -183,7 +226,7 @@ describe('tui-idle evidence ranking', () => {
// The renderer republishes: a brand-new object replaces the captured one.
live = makeTuiIdleLeaf({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE })
registered.lastOutputAt = Date.now() - QUIESCENCE_MS * 10
registered.lastOutputAt = Date.now() - OUTPUT_AGE_FIXTURE_MS * 10
await advanceWhileStreaming(live, 4)
expect(settled).not.toHaveBeenCalled()
})
@@ -195,7 +238,7 @@ describe('tui-idle evidence ranking', () => {
})
const { wait } = createWait({ pty, agent: 'codex' })
const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }))
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4 + QUIESCENCE_MS)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4 + OUTPUT_AGE_FIXTURE_MS)
expect(settled).not.toHaveBeenCalled()
})
})
@@ -263,7 +306,10 @@ describe('tui-idle over the live OSC title pipeline', () => {
// The agent is mid-turn and repaints its title to the bare product name.
runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}more output\n`, Date.now())
await expect(waiting).rejects.toThrow('timeout')
await expect(waiting).resolves.toMatchObject({
satisfied: false,
readiness: { state: 'unknown' }
})
})
it('settles when the agent reports idle explicitly', async () => {
@@ -283,15 +329,19 @@ describe('tui-idle over the live OSC title pipeline', () => {
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 })
).rejects.toThrow('timeout')
).resolves.toMatchObject({ satisfied: false, readiness: { state: 'unknown' } })
})
it('still settles for an agent whose only rest signal is its name', async () => {
it('returns unknown for an agent whose only rest signal is its name', async () => {
const { runtime, handle } = await makeRuntime('grok')
runtime.onPtyData(E2E_PTY_ID, `${oscTitle('grok')}banner\n`, Date.now())
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 })
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 100 })
).resolves.toMatchObject({
condition: 'tui-idle',
satisfied: false,
readiness: { state: 'unsupported' }
})
})
})
+27 -12
View File
@@ -2,7 +2,8 @@ import type {
RuntimeTerminalState,
RuntimeTerminalWait,
RuntimeTerminalWaitBlockedReason,
RuntimeTerminalWaitCondition
RuntimeTerminalWaitCondition,
RuntimeTerminalReadiness
} from '../../shared/runtime-types'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
@@ -25,7 +26,8 @@ export function getTerminalState(leaf: ReadonlyTerminalStateRecord): RuntimeTerm
export function buildTerminalWaitResult(
handle: string,
condition: RuntimeTerminalWaitCondition,
leaf: ReadonlyTerminalStateRecord
leaf: ReadonlyTerminalStateRecord,
readiness?: RuntimeTerminalReadiness
): RuntimeTerminalWait {
return buildTerminalWait(
handle,
@@ -33,7 +35,8 @@ export function buildTerminalWaitResult(
getTerminalState(leaf),
leaf.lastExitCode,
undefined,
leaf.lastExitCause
leaf.lastExitCause,
readiness
)
}
@@ -41,7 +44,8 @@ export function buildTerminalWaitBlockedResult(
handle: string,
condition: RuntimeTerminalWaitCondition,
leaf: ReadonlyTerminalStateRecord,
blockedReason: RuntimeTerminalWaitBlockedReason
blockedReason: RuntimeTerminalWaitBlockedReason,
readiness?: RuntimeTerminalReadiness
): RuntimeTerminalWait {
return buildTerminalWait(
handle,
@@ -49,14 +53,16 @@ export function buildTerminalWaitBlockedResult(
getTerminalState(leaf),
leaf.lastExitCode,
blockedReason,
leaf.lastExitCause
leaf.lastExitCause,
readiness ?? { state: 'blocked', source: 'screen' }
)
}
export function buildPtyTerminalWaitResult(
handle: string,
condition: RuntimeTerminalWaitCondition,
pty: ReadonlyTerminalStateRecord
pty: ReadonlyTerminalStateRecord,
readiness?: RuntimeTerminalReadiness
): RuntimeTerminalWait {
return buildTerminalWait(
handle,
@@ -64,7 +70,8 @@ export function buildPtyTerminalWaitResult(
getPtyTerminalState(pty),
pty.lastExitCode,
undefined,
pty.lastExitCause
pty.lastExitCause,
readiness
)
}
@@ -72,7 +79,8 @@ export function buildPtyTerminalWaitBlockedResult(
handle: string,
condition: RuntimeTerminalWaitCondition,
pty: ReadonlyTerminalStateRecord,
blockedReason: RuntimeTerminalWaitBlockedReason
blockedReason: RuntimeTerminalWaitBlockedReason,
readiness?: RuntimeTerminalReadiness
): RuntimeTerminalWait {
return buildTerminalWait(
handle,
@@ -80,7 +88,8 @@ export function buildPtyTerminalWaitBlockedResult(
getPtyTerminalState(pty),
pty.lastExitCode,
blockedReason,
pty.lastExitCause
pty.lastExitCause,
readiness ?? { state: 'blocked', source: 'screen' }
)
}
@@ -90,16 +99,22 @@ export function buildTerminalWait(
status: RuntimeTerminalState,
exitCode: number | null,
blockedReason?: RuntimeTerminalWaitBlockedReason,
exitCause?: TerminalExitCause | null
exitCause?: TerminalExitCause | null,
readiness?: RuntimeTerminalReadiness
): RuntimeTerminalWait {
const satisfied =
condition === 'tui-idle' && readiness
? readiness.state === 'ready'
: blockedReason === undefined
return {
handle,
condition,
satisfied: blockedReason === undefined,
satisfied,
status,
exitCode,
...(exitCause ? { exitCause } : {}),
...(blockedReason ? { blockedReason } : {})
...(blockedReason ? { blockedReason } : {}),
...(readiness ? { readiness } : {})
}
}
@@ -6,7 +6,7 @@ import type { TuiAgent } from '../../shared/tui-agent'
// Follow-ons to #6011. The evidence ranking that fixed the wait path did not reach two
// other consumers of the same signal: mailbox delivery, which TYPES INTO the pane, and
// the idle poll's quiescence gate, which read a missing output clock as "never quiet".
// the idle poll, which used to promote missing output to readiness.
const WORKTREE_ID = 'repo-1::/tmp/followups'
const TAB_ID = 'c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1'
@@ -64,9 +64,8 @@ function watchDelivery(runtime: OrcaRuntimeService) {
.mockImplementation(() => {})
}
// Why fake timers: the retry fires on a real 3s quiescence window, and asserting around it
// with wall-clock sleeps made the result depend on how promptly a loaded CI runner schedules
// an interval. The clock is the thing under test, so it has to be the deterministic part.
// Why fake timers: readiness waits and delivery observations are time-sensitive; wall-clock
// sleeps make the assertions depend on how promptly a loaded CI runner schedules an interval.
describe('mailbox delivery honours the tui-idle evidence ranking', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -96,31 +95,29 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => {
expect(deliver).toHaveBeenCalled()
})
// Why this case exists: the wait path POLLS, so weak evidence that only becomes valid
// with time eventually satisfies it. Delivery is edge-driven with no poll behind it, so a
// refusal at an edge is final unless another edge arrives. A hookless Codex never emits an
// explicit `X ready`, so without a retry the queued message strands permanently once the
// pane falls quiet — trading a visible mis-delivery for an invisible lost message.
it('retries a refused delivery once the pane falls quiet', async () => {
// Why this case exists: the wait path can re-evaluate evidence, but delivery is edge-driven
// with no time-based promotion behind it. A refusal remains parked until an actual readiness
// fact arrives. A hookless Codex never
// emits an explicit `X ready`, so its queued message stays visible for manual recovery rather
// than trading an unsafe injection for an invisible lost message.
it('does not unlock a refused delivery when the pane merely falls quiet', async () => {
const { runtime } = await makeRuntime('codex')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now())
runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
// Output stops. No further title frame and no renderer graph sync — a daemon-hosted
// pane has nobody publishing one, so nothing re-fires an edge on its own.
// Output stops. No readiness fact arrived, so silence cannot unlock a write into the pane.
await vi.advanceTimersByTimeAsync(5_000)
expect(deliver).toHaveBeenCalled()
expect(deliver).not.toHaveBeenCalled()
})
it('does not retry into a pane that went busy again', async () => {
const { runtime } = await makeRuntime('codex')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now())
// Keep the stream alive across the whole retry window.
// Deterministic streaming: one chunk every 250ms of virtual time, so the gap between
// chunks can never drift past the quiescence window the way a real interval can.
// Keep the stream active while the shared poll runs; no elapsed-silence retry may fire.
// Deterministic streaming uses one chunk every 250ms of virtual time.
for (let tick = 0; tick < 20; tick += 1) {
runtime.onPtyData(PTY_ID, 'more output\n', Date.now())
await vi.advanceTimersByTimeAsync(250)
@@ -141,14 +138,14 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => {
await vi.advanceTimersByTimeAsync(100)
runtime.onPtyData(PTY_ID, osc('Codex ready'), Date.now())
// Promptly, on the ready title itself — not after waiting out a quiescence window.
// Promptly, on the ready title itself — not after an elapsed-silence delay.
expect(deliver).toHaveBeenCalled()
})
// Case C: the agent's own status stream vetoes the idle title, then reports done with no
// edge behind it. `working` stays fresh for 30 minutes, so without a re-offer the veto
// outlives the turn it described.
it('delivers when a done status lands after the idle title was vetoed', async () => {
it('does not treat a done status without a current readiness fact as delivery permission', async () => {
const { runtime } = await makeRuntime('claude')
const deliver = watchDelivery(runtime)
runtime.onPtyData(
@@ -161,20 +158,20 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => {
runtime.onPtyData(PTY_ID, agentStatus('done', 'claude'), Date.now())
await vi.advanceTimersByTimeAsync(4_500)
expect(deliver).toHaveBeenCalled()
expect(deliver).not.toHaveBeenCalled()
})
it('still delivers for an agent whose name is its only rest signal', async () => {
it('does not deliver for an agent whose name is its only rest signal', async () => {
const { runtime } = await makeRuntime('grok', 'grok')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('⠋ Grok')}working\n`, Date.now())
runtime.onPtyData(PTY_ID, `${osc('grok')}banner\n`, Date.now())
expect(deliver).toHaveBeenCalled()
expect(deliver).not.toHaveBeenCalled()
})
})
describe('quiescence treats a missing output clock as quiet', () => {
it('settles a pane that has never produced output but holds a live agent process', async () => {
describe('missing output remains unknown', () => {
it('does not settle a pane that has never produced output even with a live agent process', async () => {
// No launch metadata: Orca did not start this agent, so the quiet-foreground lane is
// the only evidence available, and `lastOutputAt` is null because nothing ever arrived.
const { runtime, handle } = await makeRuntime(null, 'codex')
@@ -184,7 +181,11 @@ describe('quiescence treats a missing output clock as quiet', () => {
expect([...leaves.values()][0].lastOutputAt).toBeNull()
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 })
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 100 })
).resolves.toMatchObject({
condition: 'tui-idle',
satisfied: false,
readiness: { state: 'unknown' }
})
}, 20_000)
})
+66 -70
View File
@@ -1,7 +1,7 @@
import type { AgentStatus } from '../../shared/agent-detection'
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness'
import type { AgentStatusState } from '../../shared/agent-status-types'
import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title'
import { getAgentReadinessCapability } from '../../shared/agent-readiness-capabilities'
import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type'
import type { TuiAgent } from '../../shared/tui-agent'
import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection'
@@ -15,12 +15,11 @@ import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection'
* stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and
* accepting it satisfied a wait in ~0s mid-turn (#6011).
*
* 1. POSITIVE — the agent states it is ready: an explicit idle marker in its own
* 1. READY — the agent states it is ready: an explicit idle marker in its own
* title, or a known ready-prompt body.
* 2. VETO — a fresh first-party agent status (OSC 9999) saying working/blocked/
* 2. BUSY — a fresh first-party agent status (OSC 9999) saying working/blocked/
* waiting. The agent's own account of itself outranks anything inferred.
* 3. ABSENCE — a name-only title, or a quiet non-shell foreground process. A last
* resort, and only once sustained.
* 3. UNKNOWN — a title, screen, or process observation with no provider capability.
*
* Why derived here rather than stamped onto the record at write time: `syncWindowGraph`
* rebuilds every leaf from an explicit field list, so a bespoke provenance field is
@@ -36,15 +35,19 @@ export type TuiIdleEvidenceRecord = {
export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null
/** Tier 1: an idle marker the agent put in a title itself. */
export type TuiIdleObservation = {
state: 'ready' | 'busy' | 'unsupported' | 'unknown'
source: 'title' | 'screen' | 'first-party' | 'capability' | 'none'
agent: TuiAgent | null
}
/** A positive idle marker the agent put in a title itself. */
export function hasExplicitIdleTitle(
record: TuiIdleEvidenceRecord,
rendererTitle?: string | null
): boolean {
// Why lastOscTitle too, not just the renderer's pane title: a daemon-hosted or
// background pane has no renderer publishing a title, so reading only the synced
// one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the
// whole quiescence window.
// Why lastOscTitle too, not just the renderer's pane title: daemon-hosted and background panes
// may have no renderer title, while the PTY record still carries the provider's marker.
for (const title of [rendererTitle, record.lastOscTitle]) {
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
return true
@@ -53,86 +56,79 @@ export function hasExplicitIdleTitle(
return false
}
/** Tier 2: the agent's own status stream says this turn is still open. */
/** The agent's own status stream says this turn is still open. */
export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): boolean {
return isFreshNonDoneAgentStatus(status ?? undefined)
}
/**
* Whether a name-only title from `agent` may be held to the tier-3 quiescence demand.
*
* Only for agents that go on to announce rest with an explicit title of their own (the
* hook-driven `Codex ready` / `Devin ready`). Grok, Copilot, Aider, Mimo, agy and
* OpenCode emit their NAME and nothing more at rest, so holding them to it leaves no
* settle signal at all: a real idle Grok pane repaints its banner about four times a
* second forever, so the stream never quiesces and the wait runs to timeout.
*/
export function nameOnlyIdleNeedsCorroboration(
agent: TuiAgent | null | undefined,
title?: string | null
): boolean {
// Why the title fallback: an adopted pane carries no launch metadata, but its
// name-only title is exactly the thing that names the agent.
const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null)
return getSyntheticAgentTerminalTitle(resolved, 'done') !== null
function resolveObservedAgent(input: TuiIdleSatisfactionInput): TuiAgent | null {
if (input.agent) {
return input.agent
}
for (const title of [input.rendererTitle, input.record.lastOscTitle]) {
if (!title) {
continue
}
const resolved = resolveExplicitTerminalTitleAgentType(title)
if (resolved) {
return resolved
}
}
return null
}
/** Tier 3: a title-derived idle, usable only once the stream has also gone quiet. */
export function hasSustainedTitleIdle(
record: TuiIdleEvidenceRecord,
agent: TuiAgent | null | undefined,
quiescenceMs: number
): boolean {
if (record.lastAgentStatus !== 'idle') {
return false
}
if (!nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) {
// The title is the only rest signal this agent emits, so there is nothing to wait for.
return true
}
// Why not "no timestamp means nothing to debounce": an adopted or daemon-backed pane has
// no local output clock, so for an agent that WILL announce rest explicitly there is no
// corroboration available at all. Settling here let a busy Codex/Devin satisfy the wait
// from a name-only title (#6011); hold out for tier 1/2 or the caller's timeout instead.
if (record.lastOutputAt === null) {
return false
}
return Date.now() - record.lastOutputAt >= quiescenceMs
}
/**
* Tier 3, cold start: Orca launched a known agent on this PTY, so a quiet non-shell
* foreground process is an agent still booting, not one sitting at its prompt. Resolving
* on it is what let `dispatch --inject` write into a TUI that had not yet attached its
* reader and silently lose the prompt (#9976).
*/
export function quietForegroundProcessProvesTuiIdle(agent: TuiAgent | null | undefined): boolean {
return !agent
function supportsEvidence(agent: TuiAgent | null, source: 'title' | 'screen'): boolean {
return getAgentReadinessCapability(agent, 'terminal')?.evidence.includes(source) ?? false
}
export type TuiIdleSatisfactionInput = {
record: TuiIdleEvidenceRecord
/** Renderer-synced pane/tab title, when one exists. */
rendererTitle?: string | null
/** Tier 1 body evidence: a known ready prompt, or an adopted pane's explicit title.
/** Body evidence: a known ready prompt, or an adopted pane's explicit title.
* A thunk because producing it means building the pane's wait text and lowercasing it
* (~11us and a multi-KB string on a full tail); the title check below usually answers
* first, and then none of that has to happen at all. */
readPositiveBodyEvidence: () => boolean
/** Provider identity behind the body evidence, when the scanner can identify it. */
positiveBodyEvidenceAgent?: TuiAgent | null
/** Body evidence is normally a rendered terminal preview; adopted title evidence opts in. */
positiveBodyEvidenceSource?: 'title' | 'screen'
agent: TuiAgent | null | undefined
firstPartyStatus: FirstPartyAgentStatus
quiescenceMs: number
}
/** The one place the three tiers are combined; every satisfaction site routes here. */
export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean {
// Why the title before the body: both are tier 1, so either settles, but the title is a
// memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order.
if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) {
return true
}
/**
* Evaluates only evidence that can be tied to this launch's provider. Silence and process
* presence intentionally have no ready branch: they cannot prove that a composer accepts input.
*/
export function observeTuiIdle(input: TuiIdleSatisfactionInput): TuiIdleObservation {
// A prompt scanner is itself provider evidence when launch metadata is absent. It is read from
// this exact attachment, so retaining that identity is safer than treating a known prompt as
// anonymous and losing a usable readiness fact.
const agent = resolveObservedAgent(input) ?? input.positiveBodyEvidenceAgent ?? null
const bodySource = input.positiveBodyEvidenceSource ?? 'screen'
const bodyAgent = input.positiveBodyEvidenceAgent ?? agent
if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) {
return false
return { state: 'busy', source: 'first-party', agent }
}
return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs)
if (hasExplicitIdleTitle(input.record, input.rendererTitle) && supportsEvidence(agent, 'title')) {
return { state: 'ready', source: 'title', agent }
}
if (
input.readPositiveBodyEvidence() &&
bodyAgent === agent &&
supportsEvidence(agent, bodySource)
) {
return { state: 'ready', source: bodySource, agent }
}
if (!agent || getAgentReadinessCapability(agent, 'terminal')?.readiness === 'unsupported') {
return { state: agent ? 'unsupported' : 'unknown', source: 'capability', agent }
}
return { state: 'unknown', source: 'none', agent }
}
/** The one place readiness facts are combined; every satisfaction site routes here. */
export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean {
return observeTuiIdle(input).state === 'ready'
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { ALL_TUI_AGENTS } from './tui-agent-display-names'
import {
AGENT_READINESS_CAPABILITIES,
getAgentReadinessCapability,
supportsAgentPromptTurnStart
} from './agent-readiness-capabilities'
describe('agent readiness capability matrix', () => {
it('covers every launchable provider exactly once', () => {
expect(Object.keys(AGENT_READINESS_CAPABILITIES).sort()).toEqual([...ALL_TUI_AGENTS].sort())
for (const capability of Object.values(AGENT_READINESS_CAPABILITIES)) {
expect(capability.terminal.terminalReconciliation).toBeTruthy()
expect(capability.structured.terminalReconciliation).toBeTruthy()
}
})
it('keeps structured sessions limited to their acknowledged provider API', () => {
expect(getAgentReadinessCapability('claude', 'structured')).toMatchObject({
readiness: 'supported',
submission: 'native-session',
receipt: 'turn-start',
terminalReconciliation: 'native-session'
})
expect(getAgentReadinessCapability('codex', 'structured')).toMatchObject({
readiness: 'supported',
submission: 'native-session',
receipt: 'turn-start',
terminalReconciliation: 'native-session'
})
expect(getAgentReadinessCapability('aider', 'structured')).toMatchObject({
readiness: 'unsupported',
submission: 'native-session',
receipt: 'input-accepted',
terminalReconciliation: 'unsupported'
})
})
it('does not treat Antigravity captures as a complete readiness contract', () => {
expect(getAgentReadinessCapability('antigravity', 'terminal')).toMatchObject({
readiness: 'unsupported',
evidence: [],
receipt: 'input-accepted',
terminalReconciliation: 'pty-incarnation'
})
})
it('identifies the only terminal providers with correlated turn-start receipts', () => {
expect(supportsAgentPromptTurnStart('claude')).toBe(true)
expect(supportsAgentPromptTurnStart('codex')).toBe(true)
expect(supportsAgentPromptTurnStart('kimi')).toBe(false)
expect(supportsAgentPromptTurnStart(undefined)).toBe(false)
})
})
+128
View File
@@ -0,0 +1,128 @@
import type { TuiAgent } from './tui-agent'
export type AgentReadinessEvidence = 'title' | 'screen'
export type AgentPromptReceipt = 'turn-start' | 'input-accepted'
/** How a launch path can reconcile the terminal after a delivery/readiness ambiguity. */
export type AgentTerminalReconciliation = 'pty-incarnation' | 'native-session' | 'unsupported'
export type AgentLaunchPathCapability = {
/** Whether Orca has a positive readiness observation for this launch path. */
readiness: 'supported' | 'unsupported'
evidence: readonly AgentReadinessEvidence[]
submission: 'pty-composer' | 'native-session'
receipt: AgentPromptReceipt
terminalReconciliation: AgentTerminalReconciliation
}
export type AgentReadinessCapability = {
terminal: AgentLaunchPathCapability
structured: AgentLaunchPathCapability
}
const unsupportedTerminal: AgentLaunchPathCapability = {
readiness: 'unsupported',
evidence: [],
submission: 'pty-composer',
receipt: 'input-accepted',
terminalReconciliation: 'pty-incarnation'
}
const unsupportedStructured: AgentLaunchPathCapability = {
readiness: 'unsupported',
evidence: [],
submission: 'native-session',
receipt: 'input-accepted',
terminalReconciliation: 'unsupported'
}
const titleTerminal = (receipt: AgentPromptReceipt = 'input-accepted') => ({
readiness: 'supported' as const,
evidence: ['title'] as const,
submission: 'pty-composer' as const,
receipt,
terminalReconciliation: 'pty-incarnation' as const
})
const screenTerminal = (receipt: AgentPromptReceipt = 'input-accepted') => ({
readiness: 'supported' as const,
evidence: ['screen'] as const,
submission: 'pty-composer' as const,
receipt,
terminalReconciliation: 'pty-incarnation' as const
})
const structuredSession = {
readiness: 'supported',
evidence: [],
submission: 'native-session',
receipt: 'turn-start',
terminalReconciliation: 'native-session'
} as const satisfies AgentLaunchPathCapability
/**
* The facts Orca can prove for each vendor and launch path.
*
* This is deliberately conservative: a vendor with a composer but no captured, attachment-bound
* readiness signal remains unsupported rather than being promoted by a title or silence guess.
* `satisfies` keeps additions to TuiAgent from silently escaping the matrix.
*/
export const AGENT_READINESS_CAPABILITIES = {
claude: { terminal: titleTerminal('turn-start'), structured: structuredSession },
'claude-agent-teams': { terminal: unsupportedTerminal, structured: unsupportedStructured },
openclaude: { terminal: unsupportedTerminal, structured: unsupportedStructured },
codex: {
terminal: {
readiness: 'supported',
evidence: ['title', 'screen'],
submission: 'pty-composer',
receipt: 'turn-start',
terminalReconciliation: 'pty-incarnation'
},
structured: structuredSession
},
autohand: { terminal: unsupportedTerminal, structured: unsupportedStructured },
ante: { terminal: unsupportedTerminal, structured: unsupportedStructured },
trae: { terminal: unsupportedTerminal, structured: unsupportedStructured },
opencode: { terminal: titleTerminal(), structured: unsupportedStructured },
'mimo-code': { terminal: unsupportedTerminal, structured: unsupportedStructured },
pi: { terminal: titleTerminal(), structured: unsupportedStructured },
omp: { terminal: titleTerminal(), structured: unsupportedStructured },
'prime-agent': { terminal: unsupportedTerminal, structured: unsupportedStructured },
gemini: { terminal: titleTerminal(), structured: unsupportedStructured },
// Captured screens are not sufficient to claim all startup/account/mode cases; keep this
// provider explicitly unsupported until the missing evidence is recorded.
antigravity: { terminal: unsupportedTerminal, structured: unsupportedStructured },
aider: { terminal: unsupportedTerminal, structured: unsupportedStructured },
goose: { terminal: unsupportedTerminal, structured: unsupportedStructured },
amp: { terminal: unsupportedTerminal, structured: unsupportedStructured },
kilo: { terminal: unsupportedTerminal, structured: unsupportedStructured },
kiro: { terminal: unsupportedTerminal, structured: unsupportedStructured },
crush: { terminal: unsupportedTerminal, structured: unsupportedStructured },
aug: { terminal: unsupportedTerminal, structured: unsupportedStructured },
cline: { terminal: unsupportedTerminal, structured: unsupportedStructured },
codebuff: { terminal: unsupportedTerminal, structured: unsupportedStructured },
'command-code': { terminal: unsupportedTerminal, structured: unsupportedStructured },
continue: { terminal: unsupportedTerminal, structured: unsupportedStructured },
cursor: { terminal: screenTerminal(), structured: unsupportedStructured },
droid: { terminal: titleTerminal(), structured: unsupportedStructured },
kimi: { terminal: unsupportedTerminal, structured: unsupportedStructured },
'mistral-vibe': { terminal: unsupportedTerminal, structured: unsupportedStructured },
'qwen-code': { terminal: unsupportedTerminal, structured: unsupportedStructured },
rovo: { terminal: unsupportedTerminal, structured: unsupportedStructured },
hermes: { terminal: titleTerminal(), structured: unsupportedStructured },
openclaw: { terminal: unsupportedTerminal, structured: unsupportedStructured },
copilot: { terminal: unsupportedTerminal, structured: unsupportedStructured },
grok: { terminal: unsupportedTerminal, structured: unsupportedStructured },
devin: { terminal: titleTerminal(), structured: unsupportedStructured }
} satisfies Record<TuiAgent, AgentReadinessCapability>
export function getAgentReadinessCapability(
agent: TuiAgent | null | undefined,
path: keyof AgentReadinessCapability
): AgentLaunchPathCapability | null {
return agent ? AGENT_READINESS_CAPABILITIES[agent][path] : null
}
export function supportsAgentPromptTurnStart(agent: TuiAgent | null | undefined): boolean {
return getAgentReadinessCapability(agent, 'terminal')?.receipt === 'turn-start'
}
+3 -16
View File
@@ -8,6 +8,8 @@ import type { ExecutionHostId } from './execution-host'
import type { PtyIncarnationId } from './pty-incarnation'
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts'
import type { RuntimeTerminalReadiness } from './runtime-terminal-readiness'
import type { RuntimeTerminalPromptDelivery } from './runtime-terminal-prompt-delivery'
import type { TabGroupLayoutNode } from './tab-types'
import type { TerminalExitCause } from './terminal-exit-cause'
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
@@ -218,22 +220,6 @@ export type RuntimeTerminalSend = {
prompt?: RuntimeTerminalPromptDelivery
}
export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started'
export type RuntimeTerminalPromptDelivery = {
requestId: string
stages: RuntimeTerminalPromptStage[]
provider: 'claude' | 'codex' | 'unsupported' | 'old-host'
observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission'
processIncarnation: string
generation: number
baselineWorkingSequence: number
/** Hook turn-start timestamp before this prompt was accepted. */
baselineExplicitWorkingStartedAt?: number | null
/** Permission observations seen before this prompt was accepted. */
baselinePermissionSequence?: number
}
export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null
export type RuntimeTerminalAgentStatus = {
@@ -356,4 +342,5 @@ export type RuntimeTerminalWait = {
exitCode: number | null
exitCause?: TerminalExitCause
blockedReason?: RuntimeTerminalWaitBlockedReason
readiness?: RuntimeTerminalReadiness
}
@@ -0,0 +1,15 @@
export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started'
export type RuntimeTerminalPromptDelivery = {
requestId: string
stages: RuntimeTerminalPromptStage[]
provider: 'claude' | 'codex' | 'unsupported' | 'old-host'
observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission'
processIncarnation: string
generation: number
baselineWorkingSequence: number
/** Hook turn-start timestamp before this prompt was accepted. */
baselineExplicitWorkingStartedAt?: number | null
/** Permission observations seen before this prompt was accepted. */
baselinePermissionSequence?: number
}
+7
View File
@@ -0,0 +1,7 @@
import type { TuiAgent } from './tui-agent'
export type RuntimeTerminalReadiness = {
state: 'ready' | 'blocked' | 'busy' | 'unsupported' | 'unknown'
source: 'title' | 'screen' | 'first-party' | 'capability' | 'none'
agent?: TuiAgent
}
+5 -2
View File
@@ -160,8 +160,6 @@ export type {
RuntimeTerminalOrphanTopologyGroup,
RuntimeTerminalOrphanTopologyTab,
RuntimeTerminalPresentation,
RuntimeTerminalPromptDelivery,
RuntimeTerminalPromptStage,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalResolvePane,
@@ -182,6 +180,11 @@ export type {
RuntimeWorktreeTerminalCloseResult,
RuntimeWorktreeTerminalSleepResult
} from './runtime-terminal-contracts'
export type {
RuntimeTerminalPromptDelivery,
RuntimeTerminalPromptStage
} from './runtime-terminal-prompt-delivery'
export type { RuntimeTerminalReadiness } from './runtime-terminal-readiness'
export type {
RuntimeGitCheckoutResult,
RuntimeGitLocalBranches,