refactor(agent-launch): collapse the readiness budget to one flat timeout

The per-signal budget (marker 20s / quiet-window 8s) tied the timeout to how
readiness is DETECTED. The budget is really a property of how slowly an agent
can boot — a marker, a quiet window, and a process check all wait out the same
cold start — so one number covers all three signals.

Replaces draftPasteReadyBudgetMs() with DRAFT_PASTE_READY_TIMEOUT_MS: drops a
constant, a branch, and two tests, and removes the only reason a delivery path
needed to know which signal class it was using.

Cost: a launch that never emits DECSET 2004 now surfaces its 'prompt not sent'
toast at 20s instead of 8s. That is the failed-launch path only; successful
markerless delivery still resolves on the 1.5s quiet window as before.
This commit is contained in:
Brennan Benson
2026-08-08 11:54:34 -07:00
parent aae223a28f
commit 5b140a778c
6 changed files with 41 additions and 70 deletions
+2 -2
View File
@@ -418,7 +418,7 @@ import {
} from '../../shared/tui-agent-config'
import {
createDraftPasteReadyScanner,
draftPasteReadyBudgetMs
DRAFT_PASTE_READY_TIMEOUT_MS
} from '../../shared/draft-paste-ready-scanner'
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
import {
@@ -21374,7 +21374,7 @@ export class OrcaRuntimeService {
// delivery paths do, instead of silently dropping the prompt (STA-3367).
hardTimer = setTimeout(() => {
void this.resolveDraftPastePtyIfAgentOwns(ptyId, agent).then(finish)
}, draftPasteReadyBudgetMs(readySignal))
}, DRAFT_PASTE_READY_TIMEOUT_MS)
})
}
@@ -6,18 +6,16 @@
* scanner, the shared budget, and the fallback are all exercised.
*/
import { beforeEach, afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { draftPasteReadyBudgetMs } from '../../shared/draft-paste-ready-scanner'
import { DRAFT_PASTE_READY_TIMEOUT_MS } from '../../shared/draft-paste-ready-scanner'
import { OrcaRuntimeService } from './orca-runtime'
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything'
const PTY_ID = 'pty-1'
const HANDLE = 'term-1'
// Why: pull the budgets from the shared policy rather than restating them, so
// this fails if the runtime waiter ever stops consuming it. The literal values
// are pinned once, in draft-paste-ready-scanner.test.ts.
const CODEX_MARKER_BUDGET_MS = draftPasteReadyBudgetMs('codex-composer-prompt')
const QUIET_WINDOW_BUDGET_MS = draftPasteReadyBudgetMs('render-quiet-after-bracketed-paste')
// Why: the old budget that dropped the prompt, kept only to prove the waiter now
// outlasts it. The current value is pinned in draft-paste-ready-scanner.test.ts.
const OLD_DROPPED_AT_MS = 8000
type RuntimeHost = {
getLivePtyForHandle: (handle: string) => { pty: { ptyId: string } } | null
@@ -80,18 +78,18 @@ describe('waitForStartupDraftReady', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('waits the full marker budget before giving up on a cold codex composer', async () => {
it('waits the full budget before giving up on a cold codex composer', async () => {
const promise = waitForStartupDraftReady.call(host, HANDLE, 'codex')
await Promise.resolve()
observer?.(DECSET_BRACKETED_PASTE)
// The old markerless-length budget would already have resolved (to null) here.
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_BUDGET_MS)
// The old budget would already have resolved (to null) here.
await vi.advanceTimersByTimeAsync(OLD_DROPPED_AT_MS)
expect(getForegroundProcess).not.toHaveBeenCalled()
// Still inside the marker budget: a late glyph is the real ready proof, and
// must win over the ownership fallback.
await vi.advanceTimersByTimeAsync(CODEX_MARKER_BUDGET_MS - QUIET_WINDOW_BUDGET_MS - 1)
// Still inside the budget: a late glyph is the real ready proof, and must
// win over the ownership fallback.
await vi.advanceTimersByTimeAsync(DRAFT_PASTE_READY_TIMEOUT_MS - OLD_DROPPED_AT_MS - 1)
observer?.(CODEX_COMPOSER_PROMPT_RENDER)
await expect(promise).resolves.toBe(PTY_ID)
expect(getForegroundProcess).not.toHaveBeenCalled()
@@ -105,7 +103,7 @@ describe('waitForStartupDraftReady', () => {
const promise = waitForStartupDraftReady.call(host, HANDLE, 'codex')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(CODEX_MARKER_BUDGET_MS)
await vi.advanceTimersByTimeAsync(DRAFT_PASTE_READY_TIMEOUT_MS)
await expect(promise).resolves.toBe(PTY_ID)
expect(getForegroundProcess).toHaveBeenCalledWith(PTY_ID)
@@ -117,7 +115,7 @@ describe('waitForStartupDraftReady', () => {
const promise = waitForStartupDraftReady.call(host, HANDLE, 'codex')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(CODEX_MARKER_BUDGET_MS)
await vi.advanceTimersByTimeAsync(DRAFT_PASTE_READY_TIMEOUT_MS)
await expect(promise).resolves.toBeNull()
})
@@ -127,7 +125,7 @@ describe('waitForStartupDraftReady', () => {
const promise = waitForStartupDraftReady.call(host, HANDLE, 'codex')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(CODEX_MARKER_BUDGET_MS)
await vi.advanceTimersByTimeAsync(DRAFT_PASTE_READY_TIMEOUT_MS)
await expect(promise).resolves.toBeNull()
})
@@ -306,7 +306,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
import {
createDraftPasteReadyScanner,
draftPasteReadyBudgetMs
DRAFT_PASTE_READY_TIMEOUT_MS
} from '../../../../shared/draft-paste-ready-scanner'
import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content'
import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer'
@@ -4883,7 +4883,7 @@ export function connectPanePty(
startupDraftHardTimer = setTimeout(() => {
startupDraftHardTimer = null
void deliverStartupDraftIfAgentOwnsPty()
}, draftPasteReadyBudgetMs(startupDraftReadySignal))
}, DRAFT_PASTE_READY_TIMEOUT_MS)
}
const armStartupDraftQuietTimer = (): void => {
if (!startupDraftReadyScanner || startupDraftPasteSettled) {
+4 -5
View File
@@ -1,6 +1,6 @@
import type { TuiAgent, GlobalSettings } from '../../../shared/types'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { draftPasteReadyBudgetMs } from '../../../shared/draft-paste-ready-scanner'
import { DRAFT_PASTE_READY_TIMEOUT_MS } from '../../../shared/draft-paste-ready-scanner'
import { useAppStore } from '@/store'
import {
inspectRuntimeTerminalProcess,
@@ -68,8 +68,7 @@ export function getSettingsForAgentTabRuntimeOwner(
* Returns true when the paste was issued, false on timeout or missing
* PTY. `onTimeout` lets the caller surface a UI hint (e.g. toast) when
* the agent doesn't reach a ready state. `timeoutMs` overrides the
* per-signal readiness budget only; waiting for the PTY to spawn keeps its
* own fixed budget.
* readiness budget only; waiting for the PTY to spawn keeps its own budget.
*
* Readiness combines DECSET 2004 with one agent-specific follow-up signal:
* 1. `\x1b[?2004h` (DECSET 2004 — bracketed-paste-enable) on the PTY
@@ -110,7 +109,7 @@ export async function pasteDraftWhenAgentReady(args: {
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
// Why: the readiness budget starts once the PTY exists, so a slow spawn
// shortens nothing — a cold composer still gets its full window.
const budget = timeoutMs ?? draftPasteReadyBudgetMs(readySignal)
const budget = timeoutMs ?? DRAFT_PASTE_READY_TIMEOUT_MS
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
if (!ready) {
// Why: fast-starting TUIs can emit the paste-ready escape sequence before
@@ -153,7 +152,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: {
const settings = getSettingsForAgentTabRuntimeOwner(tabId)
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const budget = timeoutMs ?? draftPasteReadyBudgetMs(readySignal)
const budget = timeoutMs ?? DRAFT_PASTE_READY_TIMEOUT_MS
const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings)
if (!ready) {
const fallbackReady = agentConfig
+9 -24
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { createDraftPasteReadyScanner, draftPasteReadyBudgetMs } from './draft-paste-ready-scanner'
import {
createDraftPasteReadyScanner,
DRAFT_PASTE_READY_TIMEOUT_MS
} from './draft-paste-ready-scanner'
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const SHOW_CURSOR = '\x1b[?25h'
@@ -137,28 +140,10 @@ describe('createDraftPasteReadyScanner', () => {
})
})
describe('draftPasteReadyBudgetMs', () => {
it('gives marker-gated signals room for a cold boot (STA-3367)', () => {
// Why: the marker is a positive proof, so waiting longer can only tolerate a
// slow first-run codex — it can never paste into a composer that isn't there.
expect(draftPasteReadyBudgetMs('codex-composer-prompt')).toBe(20000)
expect(draftPasteReadyBudgetMs('render-cursor-after-bracketed-paste')).toBe(20000)
})
it('keeps the markerless quiet window tight', () => {
// Why: silence is a guess, not a proof; a longer budget just delays a wrong answer.
expect(draftPasteReadyBudgetMs('render-quiet-after-bracketed-paste')).toBe(8000)
})
it('gives every marker-gated signal a strictly larger budget than the quiet window', () => {
// Guards the invariant rather than the numbers: a new marker signal added to
// the scanner must not silently inherit the tighter markerless budget.
const quiet = draftPasteReadyBudgetMs('render-quiet-after-bracketed-paste')
for (const signal of [
'codex-composer-prompt',
'render-cursor-after-bracketed-paste'
] as const) {
expect(draftPasteReadyBudgetMs(signal)).toBeGreaterThan(quiet)
}
describe('DRAFT_PASTE_READY_TIMEOUT_MS', () => {
it('leaves room for a cold-boot composer (STA-3367)', () => {
// Why: the reported drop was a first-run codex composer mounting past the old
// 8s budget. Pinned once here; every delivery path imports rather than restates it.
expect(DRAFT_PASTE_READY_TIMEOUT_MS).toBe(20000)
})
})
+11 -22
View File
@@ -20,32 +20,21 @@ export type DraftPasteReadyScanResult = {
armQuietTimer: boolean
}
// Why: the markerless quiet-window signal guesses readiness from output silence,
// so a long budget just delays a wrong answer. Keep it tight.
const QUIET_WINDOW_READY_TIMEOUT_MS = 8000
// Why: marker-gated signals (Codex '›', opencode show-cursor) are positive
// readiness proofs — the paste fires only when the marker actually renders, so a
// longer budget can never paste prematurely; it only tolerates a slow cold boot.
// First-run codex on a cold app can take >8s to mount its composer, past which
// the old budget gave up and dropped the launch prompt (STA-3367).
const MARKER_READY_TIMEOUT_MS = 20000
/**
* How long a delivery path should wait for `readySignal` before falling back.
* Backstop for every draft-paste delivery path: how long to wait for a ready
* signal before falling back to a best-effort, ownership-checked paste.
*
* Lives beside the scanner because the budget is a property of the signal — only
* this module knows which signals are marker-gated — not of the calling path. All
* three draft-delivery owners (renderer tab paste, renderer startup paste, main
* runtime startup paste) consume this so the policy cannot drift between them.
* One budget for all signals. The value is a property of how slowly an agent can
* boot, not of how readiness is detected — a marker, a quiet window, and a
* process check all wait on the same cold start. First-run codex on a cold app
* can take >8s to mount its composer, past which the old budget gave up and
* dropped the launch prompt (STA-3367).
*
* This is a backstop, not a correctness gate: every caller must still attempt a
* best-effort delivery when the agent owns the PTY after the budget expires.
* Lives beside the scanner so all three delivery owners (renderer tab paste,
* renderer startup paste, main runtime startup paste) share one policy and
* cannot drift apart.
*/
export function draftPasteReadyBudgetMs(readySignal: DraftPasteReadySignal): number {
return readySignal === 'render-quiet-after-bracketed-paste'
? QUIET_WINDOW_READY_TIMEOUT_MS
: MARKER_READY_TIMEOUT_MS
}
export const DRAFT_PASTE_READY_TIMEOUT_MS = 20000
/**
* Pure, incremental scanner shared by the renderer and main-process draft-paste