diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 6d7e59c383f..fc86de47451 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -58,7 +58,15 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) } ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus - if (agentStatus === 'idle' && prevStatus !== 'idle') { + // Why `!== 'permission'` rather than `!== 'idle'`: a name-only idle leaves the waiter + // parked on its poll, so the later explicit idle is an idle→idle step that still has + // to be offered. The resolve helper re-ranks and returns early when it is not yet + // satisfying evidence, which is what the old edge guard was really protecting. + // Why also gated on a change: re-ranking an unchanged idle title cannot reach a + // different verdict. Tier 1 and 2 depend only on the title and the status; tier 3 + // needs the stream to go quiet, which cannot happen on the frame that just wrote to + // it. Repainted frames would otherwise re-scan the pane tail for nothing. + if (agentStatus === 'idle' && prevStatus !== 'permission' && ptyRecordChanged) { this.resolvePtyTuiIdleWaiters(pty, ptyId) } const shouldDelayMobileSnapshot = @@ -95,6 +103,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // the shell took over the title — the stuck-spinner bug in #1437. const prevStatus = leaf.lastAgentStatus const prevObservedLive = leaf.lastAgentStatusObservedLive + const prevLeafTitle = leaf.lastOscTitle leaf.lastOscTitle = recordedTitle leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() // Why: when a new OSC title doesn't classify as an agent state (e.g. @@ -112,7 +121,15 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // working→idle transition that never comes. Permission→idle is excluded: // it means the agent was blocked on user approval and the user said no, // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { + // Why not `prevStatus !== 'idle'`: see the pty branch — the resolve helper re-ranks, + // so an idle→idle step that upgrades weak evidence to explicit must still be offered. + // Why the change gate: see the pty branch — an unchanged idle title re-ranks to the + // same verdict, so repainted frames must not re-scan the tail. + if ( + agentStatus === 'idle' && + prevStatus !== 'permission' && + (prevStatus !== agentStatus || prevLeafTitle !== recordedTitle) + ) { this.resolveTuiIdleWaiters(leaf) } // Why the second condition: push delivery is gated on LIVE idle, so its @@ -155,6 +172,9 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null pty.lastAgentStatus = null + // Why: the prior process's first-party status would otherwise veto idle for its + // replacement — a stale `working` keeps tui-idle unresolved on the new generation. + pty.lastExplicitAgentStatus = null // Why: the prior process's live frames say nothing about the replacement, // so the seed a same-id restore applies must not inherit its authority. pty.lastAgentStatusObservedLive = false diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index df384980fef..0d29d038746 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -215,6 +215,19 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution for (const payload of agentStatusChunk.payloads) { titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) } + // Why on the PTY record: the retained status snapshots are keyed by paneKey, which a + // background CLI-created PTY may never have. `terminal wait --for tui-idle` still needs + // the agent's own account of itself, and ptyId is the only identity that path always holds. + const latestAgentStatus = agentStatusChunk.payloads.at(-1) + if (latestAgentStatus) { + const ptyRecord = this.ptysById.get(ptyId) + if (ptyRecord) { + ptyRecord.lastExplicitAgentStatus = { + state: latestAgentStatus.state, + updatedAt: Date.now() + } + } + } titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { titleScanData: titleInput }) diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index 63ac6d4b30c..221bb873545 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -4,7 +4,13 @@ import { OrcaRuntimeWithBindPtyIncarnationHandle } from './orca-runtime-bind-pty import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal-wait-results' import type { AgentStatus } from '../../shared/agent-detection' -import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' +import { + detectExplicitIdleStatusFromTitle, + isKnownReadyPromptPreview +} 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' export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle { protected resolveExitWaiters(leaf: RuntimeLeafRecord): void { @@ -43,6 +49,12 @@ 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)) { + return + } for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) @@ -78,6 +90,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc if (!waiters || waiters.size === 0) { return } + // Why: same re-ranking as resolveTuiIdleWaiters above. + if (!this.isTuiIdleSatisfiedForPty(pty)) { + return + } for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) @@ -86,6 +102,35 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc } // 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. + protected isTuiIdleSatisfiedForLeaf(leaf: RuntimeLeafRecord): boolean { + return isTuiIdleSatisfied({ + record: leaf, + rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null, + readPositiveBodyEvidence: () => + isKnownReadyPromptPreview( + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + ), + agent: this.getPaneAgentForTuiIdle(leaf.ptyId), + firstPartyStatus: + (leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null, + quiescenceMs: TUI_IDLE_QUIESCENCE_MS + }) + } + + protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean { + return isTuiIdleSatisfied({ + record: pty, + readPositiveBodyEvidence: () => + this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || + isKnownReadyPromptPreview( + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + ), + agent: this.getPaneAgentForTuiIdle(pty.ptyId), + firstPartyStatus: pty.lastExplicitAgentStatus ?? null, + quiescenceMs: TUI_IDLE_QUIESCENCE_MS + }) + } + protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { const title = this.getAdoptedPtyTitle(pty) return title ? detectExplicitIdleStatusFromTitle(title) : null diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index caf23eee00b..0806b705fdf 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' +import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeClientSettingsController } from './runtime-client-settings' import type { RuntimeAutomationController } from './runtime-automation-controller' @@ -263,6 +264,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = null + /** The agent Orca believes owns this pane, for tui-idle evidence ranking. Launch + * authority first; the live foreground agent covers panes Orca did not launch. */ + protected getPaneAgentForTuiIdle(ptyId: string | null | undefined): TuiAgent | null { + if (!ptyId) { + return null + } + const pty = this.ptysById.get(ptyId) + return pty?.launchAgent ?? pty?.foregroundAgent ?? null + } + protected leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by @@ -317,6 +328,10 @@ export class OrcaRuntimeWithRuntimeId { getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null, getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty), + getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), + getFirstPartyAgentStatus: (ptyId) => + (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, + getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf, resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result) }) @@ -327,6 +342,10 @@ export class OrcaRuntimeWithRuntimeId { getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle), getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(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, startVisibleReadProbe: (waiter, waiterTimeoutMs) => this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) }, diff --git a/src/main/runtime/runtime-terminal-idle-polls.test.ts b/src/main/runtime/runtime-terminal-idle-polls.test.ts index e153d60d1fa..99622ce42df 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.test.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.test.ts @@ -71,6 +71,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { getTabTitle: () => null, getForegroundProcess: () => null, getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: (waiter, result) => resolved.push({ handle: waiter.handle, result }) }) @@ -103,6 +106,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { getTabTitle: () => null, getForegroundProcess: () => null, getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: () => {} }) @@ -125,6 +131,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { getTabTitle: () => null, getForegroundProcess: () => null, getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: () => {} }) const first = makeWaiter('a') @@ -152,6 +161,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { gates.push(resolve) }), getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: (waiter) => resolved.push(waiter.handle) }) diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index eda89b6f9a9..4fb5c470c2f 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -1,7 +1,6 @@ import { isShellProcess, type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWait } from '../../shared/runtime-types' import { - detectExplicitIdleStatusFromTitle, detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './terminal-wait-detection' @@ -12,6 +11,12 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { + isTuiIdleSatisfied, + quietForegroundProcessProvesTuiIdle, + 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' @@ -21,6 +26,10 @@ type RuntimeTerminalIdlePollDependencies = { getTabTitle(tabId: string): string | null getForegroundProcess(ptyId: string): Promise | null getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | 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. */ + getLiveLeaf(leaf: RuntimeLeafRecord): RuntimeLeafRecord resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void } @@ -82,20 +91,15 @@ export class RuntimeTerminalIdlePolls { if (!this.entries.has(entry)) { return } - const { waiter, leaf } = entry + 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. + const leaf = this.deps.getLiveLeaf(entry.leaf) + const agent = this.deps.getPaneAgent(leaf.ptyId) let startedForegroundPoll = false try { - if (leaf.lastAgentStatus === 'idle') { - this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } - const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId) - if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { - this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) const blockedReason = detectTerminalWaitBlockedReason(waitText) if (blockedReason) { @@ -106,12 +110,26 @@ export class RuntimeTerminalIdlePolls { ) return } - if (isKnownReadyPromptPreview(waitText)) { + 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 + }) + ) { this.stop(entry) this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) return } - if (leaf.lastAgentStatus === null && leaf.ptyId && !entry.foregroundPollInFlight) { + if ( + leaf.lastAgentStatus === null && + quietForegroundProcessProvesTuiIdle(agent) && + leaf.ptyId && + !entry.foregroundPollInFlight + ) { const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId) if (!foregroundRead) { return @@ -119,13 +137,14 @@ export class RuntimeTerminalIdlePolls { entry.foregroundPollInFlight = true startedForegroundPoll = true const foreground = await foregroundRead + const live = this.deps.getLiveLeaf(entry.leaf) if ( foreground && !isShellProcess(foreground) && - (leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs + (live.lastOutputAt ? Date.now() - live.lastOutputAt : 0) >= this.deps.quiescenceMs ) { this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live)) } } } catch { @@ -142,13 +161,11 @@ export class RuntimeTerminalIdlePolls { return } const { waiter, pty } = entry + // 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 { - if (pty.lastAgentStatus === 'idle') { - this.stop(entry) - this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - return - } const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) const blockedReason = detectTerminalWaitBlockedReason(waitText) if (blockedReason) { @@ -160,14 +177,25 @@ export class RuntimeTerminalIdlePolls { return } if ( - this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || - isKnownReadyPromptPreview(waitText) + isTuiIdleSatisfied({ + record: pty, + readPositiveBodyEvidence: () => + this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || + isKnownReadyPromptPreview(waitText), + agent, + firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), + quiescenceMs: this.deps.quiescenceMs + }) ) { this.stop(entry) this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) return } - if (pty.lastAgentStatus === null && !entry.foregroundPollInFlight) { + if ( + pty.lastAgentStatus === null && + quietForegroundProcessProvesTuiIdle(agent) && + !entry.foregroundPollInFlight + ) { const foregroundRead = this.deps.getForegroundProcess(pty.ptyId) if (!foregroundRead) { return diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 6ccb4ed82bb..8f54856be0d 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -1,4 +1,5 @@ import type { AgentStatus } from '../../shared/agent-detection' +import type { AgentStatusState } from '../../shared/agent-status-types' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { RuntimeSyncedLeaf } from '../../shared/runtime-types' @@ -65,6 +66,10 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastExitCause: TerminalExitCause | null lastAgentStatus: AgentStatus | null lastAgentStatusObservedLive: boolean + /** Latest first-party state from the agent's own OSC 9999 status stream — what the + * agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its + * OSC title. Optional: absent until a payload lands. */ + lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null lastAgentStatusStartedAtEpochMs: number | null lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index fd92582c09e..cf095f85775 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -3,7 +3,6 @@ import type { RuntimeTerminalWaitCondition } from '../../shared/runtime-types' import { - detectExplicitIdleStatusFromTitle, detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './terminal-wait-detection' @@ -15,6 +14,8 @@ import { getTerminalState } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { isTuiIdleSatisfied, 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' @@ -27,6 +28,9 @@ type RuntimeTerminalWaitDependencies = { getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord } getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null getTabTitle(tabId: string): string | null + quiescenceMs: number + getPaneAgent(ptyId: string | null | undefined): TuiAgent | null + getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus startVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void } @@ -37,6 +41,30 @@ export class RuntimeTerminalWait { 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 + }) + } + async wait( handle: string, options?: { @@ -60,14 +88,7 @@ export class RuntimeTerminalWait { if (condition === 'tui-idle' && ptyBlockedReason) { return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) } - if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { - return buildPtyTerminalWaitResult(handle, condition, pty.pty) - } - if ( - condition === 'tui-idle' && - (this.deps.getAdoptedPtyIdleStatus(pty.pty) === 'idle' || - isKnownReadyPromptPreview(ptyWaitText)) - ) { + if (condition === 'tui-idle' && this.ptySatisfied(pty.pty, ptyWaitText)) { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } return await new Promise((resolve, reject) => { @@ -115,12 +136,7 @@ export class RuntimeTerminalWait { waiter, buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) ) - } else if (live.pty.lastAgentStatus === 'idle') { - this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) - } else if ( - this.deps.getAdoptedPtyIdleStatus(live.pty) === 'idle' || - isKnownReadyPromptPreview(livePtyWaitText) - ) { + } else if (this.ptySatisfied(live.pty, livePtyWaitText)) { this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else { this.polls.startPty(waiter, live.pty) @@ -147,18 +163,9 @@ 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' && leaf.lastAgentStatus === 'idle') { + if (condition === 'tui-idle' && this.leafSatisfied(leaf, leafWaitText)) { return buildTerminalWaitResult(handle, condition, leaf) } - if (condition === 'tui-idle') { - const fastPathTitle = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId) - if ( - (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || - isKnownReadyPromptPreview(leafWaitText) - ) { - return buildTerminalWaitResult(handle, condition, leaf) - } - } return await new Promise((resolve, reject) => { // Why: tui-idle depends on OSC title transitions from a recognized agent. @@ -214,7 +221,7 @@ export class RuntimeTerminalWait { waiter, buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) ) - } else if (live.leaf.lastAgentStatus === 'idle') { + } 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 — @@ -224,17 +231,9 @@ export class RuntimeTerminalWait { // 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. - const fastPathTitle = live.leaf.paneTitle ?? this.deps.getTabTitle(live.leaf.tabId) - if ( - (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || - isKnownReadyPromptPreview(liveLeafWaitText) - ) { - this.waiters.resolve(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else { - this.polls.startLeaf(waiter, live.leaf) - if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { - this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) - } + this.polls.startLeaf(waiter, live.leaf) + if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { + this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) } } } diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index cae25e8bfa6..08bd1d1d512 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,3 +1,4 @@ +import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' import { detectAgentStatusFromTitle, isOpenCodeNativeTitle, @@ -15,7 +16,7 @@ const CLAUDE_IDLE_PREFIX = '\u2733' const GEMINI_IDLE_PREFIX = '\u25c7' const PI_IDLE_PREFIX = '\u03c0 - ' -export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { +function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { const status = detectAgentStatusFromTitle(title) if (status !== 'idle') { return null @@ -35,6 +36,14 @@ export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | return null } +/** + * Pure in `title`, so it is memoized on the title string like the status classifier it + * wraps: the wait path re-asks for the same unchanged title on every poll tick and every + * repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). + */ +export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | null = + memoizeTitleClassification(computeExplicitIdleStatusFromTitle) + export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() const readyIndex = findKnownReadyPromptIndex(normalized) diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts new file mode 100644 index 00000000000..70605598466 --- /dev/null +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { RuntimeTerminalWait } from './runtime-terminal-wait' +import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' +import { + errorMessage, + makeTuiIdleLeaf, + makeTuiIdlePty, + makeTuiIdleRuntime +} from './tui-idle-wait-test-harness' +import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { AgentStatus } from '../../shared/agent-detection' +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { 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. + +const POLL_INTERVAL_MS = 2000 +const QUIESCENCE_MS = 3000 +const NAME_ONLY_TITLE = 'Codex' +const EXPLICIT_IDLE_TITLE = 'Codex ready' +const HANDLE = 'terminal-1' + +function createWait(options: { + pty?: RuntimePtyWorktreeRecord + leaf?: RuntimeLeafRecord + adoptedIdleStatus?: AgentStatus | null + tabTitle?: string | null + foreground?: string | null + agent?: TuiAgent | null + firstPartyStatus?: FirstPartyAgentStatus + liveLeaf?: () => RuntimeLeafRecord +}) { + const waiters = new RuntimeTerminalWaiterRegistry() + const startVisibleReadProbe = vi.fn() + const shared = { + getTabTitle: () => options.tabTitle ?? null, + getAdoptedPtyIdleStatus: () => options.adoptedIdleStatus ?? null, + getPaneAgent: () => options.agent ?? null, + getFirstPartyAgentStatus: () => options.firstPartyStatus ?? null, + quiescenceMs: QUIESCENCE_MS + } + 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) + }) + const wait = new RuntimeTerminalWait( + { + ...shared, + defaultTimeoutMs: 60_000, + getLivePty: () => (options.pty ? { pty: options.pty } : null), + getLiveLeaf: () => ({ leaf: options.leaf ?? makeTuiIdleLeaf() }), + startVisibleReadProbe + }, + waiters, + polls + ) + return { wait, waiters, polls, startVisibleReadProbe } +} + +function watch(promise: Promise) { + const settled = vi.fn() + void promise.then( + (value) => settled({ ok: value }), + (error) => settled({ error: errorMessage(error) }) + ) + return settled +} + +/** Keeps the record "streaming": output stays younger than the quiescence window. */ +async function advanceWhileStreaming( + record: { lastOutputAt: number | null }, + ticks: number +): Promise { + for (let tick = 0; tick < ticks; tick += 1) { + record.lastOutputAt = Date.now() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + } +} + +describe('tui-idle evidence ranking', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('refuses a stored name-only idle while the pane is still streaming', 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 })) + + await advanceWhileStreaming(pty, 4) + expect(settled).not.toHaveBeenCalled() + }) + + it('settles a name-only idle once the 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 })) + + await advanceWhileStreaming(pty, 2) + expect(settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + POLL_INTERVAL_MS) + expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) }) + }) + + it('settles an explicit idle title immediately, with no quiescence at all', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: EXPLICIT_IDLE_TITLE }) + const { wait } = createWait({ pty, agent: 'codex' }) + await expect( + wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) + ).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('reads an explicit idle title off the record when no renderer published one', async () => { + const leaf = makeTuiIdleLeaf({ + lastAgentStatus: 'idle', + lastOscTitle: EXPLICIT_IDLE_TITLE, + paneTitle: null + }) + const { wait } = createWait({ leaf, agent: 'codex', tabTitle: null }) + await expect( + wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) + ).resolves.toMatchObject({ satisfied: true }) + }) + + it('lets the agent own status stream veto an otherwise-quiet name-only idle', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: 'idle', + lastOscTitle: NAME_ONLY_TITLE, + lastOutputAt: Date.now() - QUIESCENCE_MS * 4 + }) + const { wait } = createWait({ + pty, + agent: 'codex', + firstPartyStatus: { state: 'working', updatedAt: Date.now() } + }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3) + 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 () => { + 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 }) + }) + + it('falls back to the title when the pane carries no launch metadata', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE }) + const { wait } = createWait({ pty, agent: null }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await advanceWhileStreaming(pty, 3) + 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. + 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 + const { wait } = createWait({ leaf: registered, agent: 'codex', liveLeaf: () => live }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + + // 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 + await advanceWhileStreaming(live, 4) + expect(settled).not.toHaveBeenCalled() + }) + + it('never settles tui-idle on a permission status', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: 'permission', + lastOscTitle: 'Codex - action required' + }) + 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) + expect(settled).not.toHaveBeenCalled() + }) +}) + +const E2E_WORKTREE_ID = 'repo-1::/tmp/name-only-idle' +const E2E_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const E2E_PTY_ID = 'pty-name-only-idle' +const WORKING_TITLE = '⠋ Codex' +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +const E2E_GRAPH = { + tabs: [ + { + tabId: 'tab-1', + worktreeId: E2E_WORKTREE_ID, + title: 'Agent', + activeLeafId: E2E_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: E2E_WORKTREE_ID, + leafId: E2E_LEAF_ID, + paneRuntimeId: 1, + ptyId: E2E_PTY_ID, + paneTitle: null, + title: '' + } + ] +} satisfies RuntimeSyncWindowGraph + +async function makeRuntime(launchAgent?: TuiAgent) { + // The agent process stays in the foreground; only its output and title move. + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/name-only-idle', + getForegroundProcess: async () => 'codex' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, E2E_GRAPH) + if (launchAgent) { + runtime.registerPty(E2E_PTY_ID, E2E_WORKTREE_ID, null, { + tabId: 'tab-1', + leafId: E2E_LEAF_ID, + incarnationId: 'name-only-incarnation', + agentLaunchAuthority: { launchToken: 'name-only-launch', launchAgent } + }) + } + const { terminals } = await runtime.listTerminals(`id:${E2E_WORKTREE_ID}`) + return { runtime, handle: terminals[0].handle } +} + +function oscTitle(title: string): string { + return `${ESC}]0;${title}${BEL}` +} + +describe('tui-idle over the live OSC title pipeline', () => { + it('does not settle on a name-only title arriving mid-stream', async () => { + const { runtime, handle } = await makeRuntime('codex') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(WORKING_TITLE)}building\n`, Date.now()) + + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 }) + // 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') + }) + + it('settles when the agent reports idle explicitly', async () => { + const { runtime, handle } = await makeRuntime('codex') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(WORKING_TITLE)}building\n`, Date.now()) + + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}more output\n`, Date.now()) + runtime.onPtyData(E2E_PTY_ID, oscTitle(EXPLICIT_IDLE_TITLE), Date.now()) + + await expect(waiting).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }) + + it('refuses a name-only title observed before the waiter registered', async () => { + const { runtime, handle } = await makeRuntime('codex') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}output\n`, Date.now()) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 }) + ).rejects.toThrow('timeout') + }) + + it('still settles 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 }) + }) +}) diff --git a/src/main/runtime/tui-idle-agent-fixture.mjs b/src/main/runtime/tui-idle-agent-fixture.mjs new file mode 100644 index 00000000000..cc12924f93c --- /dev/null +++ b/src/main/runtime/tui-idle-agent-fixture.mjs @@ -0,0 +1,21 @@ +// Real agent-TUI stand-in for tui-idle-name-only-real-pty.integration.test.ts. +// Emits a genuine name-only OSC title while streaming, then settles per mode. +const mode = process.argv[2] +const workMs = Number(process.argv[3] ?? 6000) +const osc = (title) => `]0;${title}` + +process.stdout.write(osc('Codex')) +const end = Date.now() + workMs +const streaming = setInterval(() => { + if (Date.now() >= end) { + clearInterval(streaming) + if (mode === 'explicit-idle') { + process.stdout.write(osc('Codex ready')) + } + return + } + process.stdout.write(`analysing chunk ${Date.now()}\n`) +}, 250) + +// Stay alive so the PTY foreground process remains this agent, never the shell. +setInterval(() => {}, 1 << 30) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts new file mode 100644 index 00000000000..827760020f8 --- /dev/null +++ b/src/main/runtime/tui-idle-evidence.ts @@ -0,0 +1,138 @@ +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 { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../shared/tui-agent' +import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' + +/** + * Ranking the evidence that a `tui-idle` wait may settle on. + * + * Why a ranking: a thinking TUI and a finished TUI are both silent, so the absence + * of a working marker can never prove completion. `detectAgentStatusFromTitle` + * DEFAULTS a name-only agent title to `idle` — the sidebar needs that to clear a + * 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 + * title, or a known ready-prompt body. + * 2. VETO — 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. + * + * 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 + * silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle` + * is copied, so reading the rank back off it cannot decay. + */ + +export type TuiIdleEvidenceRecord = { + lastAgentStatus: AgentStatus | null + lastOutputAt: number | null + lastOscTitle?: string | null +} + +export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null + +/** Tier 1: an 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. + for (const title of [rendererTitle, record.lastOscTitle]) { + if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { + return true + } + } + return false +} + +/** Tier 2: 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 +} + +/** 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 +} + +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. + * 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 + 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 + } + if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { + return false + } + return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs) +} diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts new file mode 100644 index 00000000000..0d265a0a5bb --- /dev/null +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -0,0 +1,140 @@ +import { fileURLToPath } from 'node:url' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from './orca-runtime' +import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness' +import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods' +import { getForegroundProcessName } from '../../relay/pty-shell-utils' + +// #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only +// OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`. +// Everything below is live — real bytes, real `ps` foreground reads, real timers — +// because the bug was a wait that returned satisfied in ~0s, so timing IS the proof. + +const FIXTURE = fileURLToPath(new URL('./tui-idle-agent-fixture.mjs', import.meta.url)) +const WORKTREE_ID = 'repo-1::/tmp/tui-idle-real-pty' +const TAB_ID = '55555555-5555-4555-8555-555555555555' +const LEAF_ID = '66666666-6666-4666-8666-666666666666' +const PTY_ID = 'pty-tui-idle-real' + +const waitMethod = TERMINAL_LIFECYCLE_METHODS.find((method) => method.name === 'terminal.wait')! + +const running: pty.IPty[] = [] + +afterEach(() => { + while (running.length > 0) { + try { + running.pop()?.kill() + } catch { + // The fixture may already be gone. + } + } +}) + +async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) { + const child = pty.spawn(process.execPath, [FIXTURE, mode, String(workMs)], { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: '/tmp' + }) + running.push(child) + + // Real foreground read against the real pty: the same helper the relay serves + // `pty.getForegroundProcess` with, so corroboration is host-produced here too. + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/tui-idle-real-pty', + getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null) + }) + runtime.attachWindow(1) + const graph: RuntimeSyncWindowGraph = { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] + } + runtime.syncWindowGraph(1, graph) + + const transcript: string[] = [] + child.onData((data) => { + transcript.push(data) + runtime.onPtyData(PTY_ID, data, Date.now()) + }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, transcript, handle: terminals[0].handle } +} + +/** Exactly what `orca terminal wait --terminal --for tui-idle` reaches over RPC. */ +async function terminalWait( + runtime: OrcaRuntimeService, + terminal: string, + timeoutMs: number +): Promise<{ satisfied: boolean; elapsedMs: number }> { + const startedAt = Date.now() + try { + const result = await waitMethod.handler( + { terminal, for: 'tui-idle', timeoutMs }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.wait reads only `runtime` off its context; the rest is request plumbing this fixture has no use for. + { runtime } as Parameters[1] + ) + return { satisfied: result.wait.satisfied === true, elapsedMs: Date.now() - startedAt } + } catch (error) { + // Why only `timeout`: an unsatisfied wait is the outcome under test, but any other + // failure means the harness broke and must not read as a passing refusal. + if ((error instanceof Error ? error.message : String(error)) !== 'timeout') { + throw error + } + return { satisfied: false, elapsedMs: Date.now() - startedAt } + } +} + +describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => { + it('does not satisfy while the real process streams under a name-only title', async () => { + const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000) + await new Promise((resolve) => setTimeout(resolve, 500)) + + // The OSC title really did reach the runtime as control bytes, not literal text. + expect(transcript.join('')).toContain(']0;Codex') + + const outcome = await terminalWait(runtime, handle, 8_000) + expect(outcome.satisfied).toBe(false) + expect(outcome.elapsedMs).toBeGreaterThanOrEqual(7_500) + }, 25_000) + + it('satisfies once the real process emits an explicit idle title', async () => { + const { runtime, handle } = await startRealAgentPane('explicit-idle', 3_000) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const outcome = await terminalWait(runtime, handle, 20_000) + expect(outcome.satisfied).toBe(true) + expect(outcome.elapsedMs).toBeGreaterThanOrEqual(1_500) + }, 28_000) + + it('satisfies once the real process goes quiet with the agent still in foreground', async () => { + const { runtime, handle } = await startRealAgentPane('quiet', 3_000) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const outcome = await terminalWait(runtime, handle, 20_000) + expect(outcome.satisfied).toBe(true) + // Corroboration is never instant: quiescence must elapse after the last byte. + expect(outcome.elapsedMs).toBeGreaterThanOrEqual(3_000) + }, 28_000) +}) diff --git a/src/main/runtime/tui-idle-wait-test-harness.ts b/src/main/runtime/tui-idle-wait-test-harness.ts new file mode 100644 index 00000000000..da9c237d2e0 --- /dev/null +++ b/src/main/runtime/tui-idle-wait-test-harness.ts @@ -0,0 +1,135 @@ +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +/** + * Shared fixtures for the tui-idle wait suites. + * + * Why a module rather than per-file helpers: the runtime's store, PTY controller and + * terminal records are wide contracts (40 members on the controller alone) and the wait + * path reads a handful of fields from each. Standing up a complete instance per test + * would bury the behaviour under fixture noise, so the partial doubles are built once + * here and every cast that needs is confined to this file. + */ + +/** The fields the tui-idle wait path actually reads off a PTY record. */ +export type TuiIdlePtyFixture = Pick< + RuntimePtyWorktreeRecord, + 'ptyId' | 'lastAgentStatus' | 'lastOscTitle' | 'lastOutputAt' | 'tailBuffer' | 'preview' +> & + Partial + +/** The fields the tui-idle wait path actually reads off a leaf record. */ +export type TuiIdleLeafFixture = Pick< + RuntimeLeafRecord, + | 'tabId' + | 'leafId' + | 'ptyId' + | 'lastAgentStatus' + | 'lastOscTitle' + | 'lastOutputAt' + | 'paneTitle' + | 'tailBuffer' + | 'preview' +> & + Partial + +// The fixture types above pin every field the wait path reads; the remaining record +// members are inert here, and the compiler still checks the pinned ones at each call site. +const asPty = (fixture: TuiIdlePtyFixture): RuntimePtyWorktreeRecord => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TuiIdlePtyFixture pins every field the wait path reads. + fixture as unknown as RuntimePtyWorktreeRecord + +const asLeaf = (fixture: TuiIdleLeafFixture): RuntimeLeafRecord => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TuiIdleLeafFixture pins every field the wait path reads. + fixture as unknown as RuntimeLeafRecord + +export function makeTuiIdlePty( + overrides: Partial = {} +): RuntimePtyWorktreeRecord { + return asPty({ + ptyId: 'pty-1', + connected: true, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastOscTitle: null, + lastOutputAt: Date.now(), + tailBuffer: [], + tailPartialLine: '', + preview: '', + ...overrides + }) +} + +export function makeTuiIdleLeaf(overrides: Partial = {}): RuntimeLeafRecord { + return asLeaf({ + tabId: 'tab-1', + leafId: 'leaf-1', + ptyId: 'pty-1', + connected: true, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastOscTitle: null, + lastOutputAt: Date.now(), + paneTitle: null, + tailBuffer: [], + tailPartialLine: '', + preview: '', + ...overrides + }) +} + +function makeStore(repoPath: string) { + return { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + setWorkspaceSession: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: repoPath, + displayName: 'fixture', + badgeColor: '#000000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + } +} + +export type TuiIdleRuntimeOptions = { + repoPath: string + getForegroundProcess: () => Promise +} + +/** A runtime wired with the narrowest store and controller the wait path needs. */ +export function makeTuiIdleRuntime(options: TuiIdleRuntimeOptions): OrcaRuntimeService { + // RuntimeStore and RuntimePtyController are wide contracts; the wait path calls only the + // members provided here, and a missing one throws loudly rather than silently passing. + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: partial store double; the wait path reads only the members defined above. + makeStore(options.repoPath) as never + ) + runtime.setPtyController( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: partial controller double; the wait path calls only the members listed here. + { + spawn: async () => ({ id: 'fixture-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess, + listProcesses: async () => [], + hasPty: () => true + } as never + ) + return runtime +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 7de6b14f1c7..e71cc8def32 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -150,6 +150,7 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'identity-consumer', paths: [ ['mobile/src/session/mobile-terminal-tab-agent.ts', 2], + ['src/main/runtime/tui-idle-evidence.ts', 2], ['src/renderer/src/lib/open-tab-occupant-agent.ts', 2], ['src/renderer/src/lib/use-tab-agent.ts', 3] ]