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