From 56e7df996d5799cf21b45ee8de160caae64ee5f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:36:27 -0700 Subject: [PATCH] feat(agent-status): make readiness evidence explicit --- .../runtime-terminal-run-observer.test.ts | 37 ++++- .../runtime-terminal-run-observer.ts | 39 ++++- .../agent-prompt-submission-verification.ts | 7 +- .../antigravity-readiness-transcripts.test.ts | 81 ++++----- src/main/runtime/orca-runtime-postlude.ts | 2 - .../orca-runtime-resolve-exit-waiters.ts | 131 ++++++--------- src/main/runtime/orca-runtime-runtime-id.ts | 16 +- ...ntime-start-tui-idle-visible-read-probe.ts | 63 +++++-- ...creation-and-orchestration-part-02.spec.ts | 17 +- ...creation-and-orchestration-part-04.spec.ts | 9 +- ...nal-creation-and-readiness-part-06.spec.ts | 19 ++- ...nal-creation-and-readiness-part-07.spec.ts | 25 ++- ...output-and-worker-recovery-part-02.spec.ts | 9 +- ...erminal-output-and-worker-recovery.spec.ts | 8 +- .../worker/composed-workers.test.ts | 19 ++- .../worker/local-worker-start.ts | 51 ++++-- .../worker/worker-release-recovery.test.ts | 11 +- .../worker/worker-release.test-support.ts | 11 +- .../worker-start-readiness-settlement.ts | 19 ++- .../worker/worker-start-receipt.ts | 4 + .../worker-start-turn-observation.test.ts | 2 +- .../worker/worker-start-turn-observation.ts | 11 +- .../worker/workers-new-worktree.test.ts | 31 +++- .../runtime/runtime-terminal-contracts.ts | 2 + .../runtime-terminal-idle-polls.test.ts | 36 +--- .../runtime/runtime-terminal-idle-polls.ts | 154 ++++++------------ .../runtime/runtime-terminal-wait-evidence.ts | 65 ++++++++ .../runtime/runtime-terminal-wait-timeouts.ts | 67 ++++++++ src/main/runtime/runtime-terminal-wait.ts | 109 +++++++------ src/main/runtime/terminal-wait-detection.ts | 40 +++-- .../terminal-wait-name-only-idle.test.ts | 118 ++++++++++---- src/main/runtime/terminal-wait-results.ts | 39 +++-- .../tui-idle-delivery-and-quiescence.test.ts | 51 +++--- src/main/runtime/tui-idle-evidence.ts | 136 ++++++++-------- .../agent-readiness-capabilities.test.ts | 54 ++++++ src/shared/agent-readiness-capabilities.ts | 128 +++++++++++++++ src/shared/runtime-terminal-contracts.ts | 19 +-- .../runtime-terminal-prompt-delivery.ts | 15 ++ src/shared/runtime-terminal-readiness.ts | 7 + src/shared/runtime-types.ts | 7 +- 40 files changed, 1080 insertions(+), 589 deletions(-) create mode 100644 src/main/runtime/runtime-terminal-wait-evidence.ts create mode 100644 src/main/runtime/runtime-terminal-wait-timeouts.ts create mode 100644 src/shared/agent-readiness-capabilities.test.ts create mode 100644 src/shared/agent-readiness-capabilities.ts create mode 100644 src/shared/runtime-terminal-prompt-delivery.ts create mode 100644 src/shared/runtime-terminal-readiness.ts diff --git a/src/main/automations/runtime-terminal-run-observer.test.ts b/src/main/automations/runtime-terminal-run-observer.test.ts index a9b307c3298..0e0a27151d8 100644 --- a/src/main/automations/runtime-terminal-run-observer.test.ts +++ b/src/main/automations/runtime-terminal-run-observer.test.ts @@ -21,7 +21,10 @@ type FakeWaiter = { timer: ReturnType } -function createFakeRuntime(initial: Partial) { +function createFakeRuntime( + initial: Partial, + readinessOptions: { resolveUnknown?: boolean } = {} +) { const pane: FakePane = { lastAgentStatus: null, paneTitle: null, @@ -44,14 +47,20 @@ function createFakeRuntime(initial: Partial) { } = { 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) { 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) diff --git a/src/main/automations/runtime-terminal-run-observer.ts b/src/main/automations/runtime-terminal-run-observer.ts index 0dc6750e8de..fb8a919a4d4 100644 --- a/src/main/automations/runtime-terminal-run-observer.ts +++ b/src/main/automations/runtime-terminal-run-observer.ts @@ -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 { +): Promise { 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 { 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 diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 39dd8fa6c4e..bca31538601 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -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(['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 { diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts index 3ac7707565f..c1cecde9834 100644 --- a/src/main/runtime/antigravity-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -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 () => { diff --git a/src/main/runtime/orca-runtime-postlude.ts b/src/main/runtime/orca-runtime-postlude.ts index ae655176c6b..ad44e946652 100644 --- a/src/main/runtime/orca-runtime-postlude.ts +++ b/src/main/runtime/orca-runtime-postlude.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index ea6329db3e6..7bba18f3a88 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -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 }) } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 00ccdde9da9..f24254cd032 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -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>() - protected leaves = new Map() // 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) }, diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index adaa5fde72f..ae7c395c7f9 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -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 }> { diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts index 9291c30c1a2..972d85a7ffb 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts @@ -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() }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts index 965a1a1bc3f..d026087bb87 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts @@ -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() } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-06.spec.ts index 6d9326b37f2..95acdcd0e28 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-06.spec.ts @@ -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' }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 682661f0fce..d9312c5c5b1 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -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 { diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index ecf2ab53678..a3393a4476f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -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. diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts index 21d1450b641..582d3ee7bbd 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts @@ -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() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index c9e0f077bc1..bd8bef21224 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -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}` }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f8cd1033c97..d4ede96e128 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -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 }) } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts index 177eb479d42..59440c516db 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts @@ -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) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts index a13ea320670..0d0be265b41 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts @@ -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) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index a3c57a357d2..8d550f6120b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -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 ) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts index f2dee00b44c..73af9b7d722 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts @@ -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` ] } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts index f1992eb9b9c..21ea5aafd81 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts @@ -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' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts index d1c9e59adfa..da244a58107 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -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.' + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 5164ac0b22f..89cc30dac28 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -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' } }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 227788af7e1..b9ffe8839cb 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 diff --git a/src/main/runtime/runtime-terminal-idle-polls.test.ts b/src/main/runtime/runtime-terminal-idle-polls.test.ts index 99622ce42df..6bc0a04d22a 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.test.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.test.ts @@ -41,6 +41,7 @@ function makeLeaf(tabId: string, overrides: Partial = {}) { 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((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) }) }) diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index e1be9654611..9e181d26b61 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -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 | 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 - } } } diff --git a/src/main/runtime/runtime-terminal-wait-evidence.ts b/src/main/runtime/runtime-terminal-wait-evidence.ts new file mode 100644 index 00000000000..506fe96c947 --- /dev/null +++ b/src/main/runtime/runtime-terminal-wait-evidence.ts @@ -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' + } +} diff --git a/src/main/runtime/runtime-terminal-wait-timeouts.ts b/src/main/runtime/runtime-terminal-wait-timeouts.ts new file mode 100644 index 00000000000..f3483ba81b3 --- /dev/null +++ b/src/main/runtime/runtime-terminal-wait-timeouts.ts @@ -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)) + ) + ) +} diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index cf095f85775..eb4a0f8d185 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -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((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((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) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 08bd1d1d512..f14005cd58a 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -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') diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index 70605598466..e7adaf36dff 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -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) { 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' } + }) }) }) diff --git a/src/main/runtime/terminal-wait-results.ts b/src/main/runtime/terminal-wait-results.ts index dad546af91f..bce0009a541 100644 --- a/src/main/runtime/terminal-wait-results.ts +++ b/src/main/runtime/terminal-wait-results.ts @@ -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 } : {}) } } diff --git a/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts index 02d3a62dcc9..4c0d610179e 100644 --- a/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts +++ b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts @@ -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) }) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 827760020f8..69913e3846d 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -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' } diff --git a/src/shared/agent-readiness-capabilities.test.ts b/src/shared/agent-readiness-capabilities.test.ts new file mode 100644 index 00000000000..6a0a5f4978d --- /dev/null +++ b/src/shared/agent-readiness-capabilities.test.ts @@ -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) + }) +}) diff --git a/src/shared/agent-readiness-capabilities.ts b/src/shared/agent-readiness-capabilities.ts new file mode 100644 index 00000000000..a348b0eabdf --- /dev/null +++ b/src/shared/agent-readiness-capabilities.ts @@ -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 + +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' +} diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index ad392a2b9a0..7397c5e84cf 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -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 } diff --git a/src/shared/runtime-terminal-prompt-delivery.ts b/src/shared/runtime-terminal-prompt-delivery.ts new file mode 100644 index 00000000000..e47c4f0e4c5 --- /dev/null +++ b/src/shared/runtime-terminal-prompt-delivery.ts @@ -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 +} diff --git a/src/shared/runtime-terminal-readiness.ts b/src/shared/runtime-terminal-readiness.ts new file mode 100644 index 00000000000..f7abd687253 --- /dev/null +++ b/src/shared/runtime-terminal-readiness.ts @@ -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 +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index b236308438d..2b7b590e233 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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,