fix: submit agent startup at the prompt without polling

This commit is contained in:
Neil
2026-09-04 16:19:02 -07:00
parent 186c91dca0
commit 1c766876f0
10 changed files with 200 additions and 98 deletions
@@ -0,0 +1,123 @@
import { appendFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createPtySubprocess } from './pty-subprocess'
import { Session } from './session'
const SHELLS = process.platform === 'win32' ? [] : ['/bin/bash', '/bin/zsh'].filter(existsSync)
const COMMAND = "printf 'AGENT_%s\\n' STARTED"
async function launch(
shell: string,
slow: boolean,
legacy = false
): Promise<{ output: string; ms: number }> {
const root = mkdtempSync(join(tmpdir(), 'orca-startup-latency-'))
const bash = shell.endsWith('bash')
const pause = slow ? 'sleep 0.6\n' : ''
const prompt = slow ? "PS1='$(sleep 0.3)prompt> '\n" : "PS1='prompt> '\n"
writeFileSync(
join(root, bash ? '.bash_profile' : '.zshrc'),
`${pause}${bash ? '' : 'setopt PROMPT_SUBST\n'}${prompt}`
)
vi.stubEnv('HOME', root)
vi.stubEnv('ZDOTDIR', root)
vi.stubEnv('ORCA_ORIG_ZDOTDIR', root)
let session: Session | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let legacyTimer: ReturnType<typeof setTimeout> | undefined
const readinessEvents: string[] = []
const started = performance.now()
try {
const subprocess = await createPtySubprocess({
sessionId: 'startup-latency',
cols: 120,
rows: 30,
cwd: root,
shellOverride: shell,
command: COMMAND,
env: { HOME: root, SHELL: shell, TERM: 'xterm-256color' }
})
session = new Session({
sessionId: 'startup-latency',
cols: 120,
rows: 30,
subprocess,
shellReadySupported: !legacy,
reportReadinessEvent: (event) => readinessEvents.push(event)
})
const active = session
return await new Promise((resolve, reject) => {
let output = ''
timer = setTimeout(
() => reject(new Error(`Startup timed out: ${JSON.stringify(output)}`)),
5000
)
active.attachClient({
onExit: () => {},
onData: (data) => {
output += data
if (output.includes('AGENT_STARTED')) {
resolve({ output, ms: performance.now() - started })
}
}
})
if (legacy) {
legacyTimer = setTimeout(() => active.write(`${COMMAND}\n`), 300)
} else {
active.write(`${COMMAND}\n`)
}
})
} finally {
clearTimeout(timer)
clearTimeout(legacyTimer)
if (session) {
await session.forceKillAndWaitForExit(3000)
session.dispose()
}
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
expect(readinessEvents).toEqual([])
}
}
describe('agent startup at the rendered shell prompt', () => {
afterEach(() => vi.unstubAllEnvs())
it.each(SHELLS)(
'%s displays the command once after slow startup and prompt expansion',
async (shell) => {
const before = await launch(shell, true, true)
expect(before.output.split(COMMAND)).toHaveLength(3)
const result = await launch(shell, true)
expect(result.output).not.toContain('orca-shell-ready')
expect(result.output.split(COMMAND)).toHaveLength(2)
expect(result.output.indexOf('prompt> ')).toBeLessThan(result.output.indexOf(COMMAND))
}
)
it.skipIf(!process.env.ORCA_STARTUP_BENCH || SHELLS.length === 0)(
'compares legacy input timing with prompt delivery',
async () => {
for (const shell of SHELLS) {
for (const slow of [false, true]) {
const legacy: number[] = []
const current: number[] = []
for (let i = 0; i < 5; i++) {
legacy.push((await launch(shell, slow, true)).ms)
const result = await launch(shell, slow)
expect(result.output).not.toContain('orca-shell-ready')
expect(result.output.split(COMMAND)).toHaveLength(2)
current.push(result.ms)
}
const result = JSON.stringify({ shell, slow, legacy, current })
if (process.env.ORCA_STARTUP_BENCH_OUTPUT) {
appendFileSync(process.env.ORCA_STARTUP_BENCH_OUTPUT, `${result}\n`)
}
console.log(result)
}
}
},
60_000
)
})
@@ -2,7 +2,6 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
export function getDaemonBashShellReadyRcfileContent(): string {
return `# Orca daemon bash shell-ready wrapper
@@ -56,10 +55,6 @@ __orca_osc133_precmd() {
unset __orca_in_command
fi
printf "\\033]133;A\\007"
# Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry)
# so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not
# displaced by one of Orca's own hooks.
[[ -n "$__orca_ready_marker" ]] && printf "${SHELL_READY_MARKER}"
return "$exit_code"
}
__orca_osc133_preexec() {
@@ -117,6 +112,11 @@ __orca_osc133_epilogue() {
unset __orca_in_prompt_command
__orca_adopt_outer_debug_trap
trap '__orca_osc133_preexec' DEBUG
# Readline renders PS1 after entering raw mode; prompt hooks still run in cooked mode.
if [[ -n "$__orca_ready_marker" ]]; then
PS1="\${PS1-}"'\\[\\e]777;orca-shell-ready\\a\\]'
__orca_ready_marker=""
fi
}
${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK}
__orca_prepend_prompt_command "__orca_osc133_precmd"
@@ -344,6 +344,15 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
itOnPosix('preserves the existing fast-start timing for fish', async () => {
await adapter.spawn({ cols: 80, rows: 24, command: 'codex', env: { SHELL: '/usr/bin/fish' } })
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n')
expect(lastSpawnOpts).not.toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
})
itOnPosix.each([
{ command: 'codex' },
{ command: 'codex', startupCommandDelivery: 'fast' as const },
+24 -2
View File
@@ -1,3 +1,6 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
@@ -9,7 +12,11 @@ import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request'
import type { ColdRestoreInfo } from './history-reader'
import { mintPtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import {
supportsPtyStartupBarrier,
shellReadyMarkerComesFromLineEditor,
resolvePtyShellPath
} from './shell-ready'
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
@@ -210,9 +217,23 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
let effectiveRows = restoreInfo?.rows ?? opts.rows
const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false
const immediateMarker =
process.platform !== 'win32' &&
shellReadyMarkerComesFromLineEditor(opts.shellOverride || resolvePtyShellPath(opts.env ?? {}))
const shellReadyTimeoutMs =
shellReadySupported &&
!immediateMarker &&
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' &&
!shouldUseShellReadyStartupDelivery({
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
})
? CODEX_SHELL_READY_TIMEOUT_MS
: undefined
const context: DaemonPtySpawnContext = {
// Older daemons also need the existing hint to enable their ready marker.
opts: opts.command ? { ...opts, startupCommandDelivery: 'shell-ready' } : opts,
opts:
opts.command && immediateMarker ? { ...opts, startupCommandDelivery: 'shell-ready' } : opts,
operation,
historyRecovery,
requestedSessionId,
@@ -226,6 +247,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
effectiveCols,
effectiveRows,
shellReadySupported,
shellReadyTimeoutMs,
historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null,
detectColdRestore
}
@@ -33,6 +33,7 @@ export type DaemonPtySpawnContext = {
effectiveCols: number
effectiveRows: number
shellReadySupported: boolean
shellReadyTimeoutMs: number | undefined
historySeedSegments: readonly string[] | null
detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise<ColdRestoreInfo | null>
}
@@ -122,6 +123,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
? undefined
: opts.terminalWindowsPowerShellImplementation,
shellReadySupported: context.attachOnly ? false : context.shellReadySupported,
...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: context.shellReadyTimeoutMs }
: {}),
...(historySeed ? { historySeed } : {}),
...(historySeedTransferId ? { historySeedTransferId } : {}),
...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress
+3 -41
View File
@@ -20,50 +20,12 @@ describe('PostReadyFlushGate', () => {
vi.useRealTimers()
})
it('waits while a slow prompt still has cooked echo enabled', async () => {
const probe = vi.fn().mockResolvedValue('other')
gate = new PostReadyFlushGate(onFlush, probe)
gate.arm(true)
await vi.advanceTimersByTimeAsync(1000)
expect(onFlush).not.toHaveBeenCalled()
expect(gate.isPending).toBe(true)
probe.mockResolvedValue('line-editor')
await vi.advanceTimersByTimeAsync(250)
expect(onFlush).toHaveBeenCalledTimes(1)
expect(gate.isPending).toBe(false)
})
it('drops an in-flight probe result after teardown', async () => {
let resolveProbe!: (state: 'line-editor') => void
gate = new PostReadyFlushGate(
onFlush,
() =>
new Promise((resolve) => {
resolveProbe = resolve
})
)
gate.arm(true)
await vi.advanceTimersByTimeAsync(POST_READY_FLUSH_DELAY_MS)
gate.clear()
resolveProbe('line-editor')
await vi.advanceTimersByTimeAsync(1000)
expect(onFlush).not.toHaveBeenCalled()
expect(gate.isPending).toBe(false)
})
it.each(['unknown', 'unavailable'])('retains the fallback when probing is %s', async (state) => {
gate = new PostReadyFlushGate(onFlush, vi.fn().mockResolvedValue(state))
it('flushes synchronously when the marker comes from the line editor', () => {
gate = new PostReadyFlushGate(onFlush, true)
gate.arm()
await vi.advanceTimersByTimeAsync(POST_READY_FLUSH_FALLBACK_MS)
expect(onFlush).toHaveBeenCalledTimes(1)
})
it('bounds waiting when a shell never enters its line editor', async () => {
gate = new PostReadyFlushGate(onFlush, vi.fn().mockResolvedValue('other'))
gate.arm(true)
await vi.advanceTimersByTimeAsync(15_500)
expect(onFlush).toHaveBeenCalledTimes(1)
expect(gate.isPending).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('does not flush immediately when armed', () => {
+10 -45
View File
@@ -1,5 +1,5 @@
import type { PtySlaveLineEditorProbe } from '../../shared/pty-slave-line-discipline-echo'
// Bash's prompt and zsh's line-init marker are ready for input immediately.
// Other shells retain the existing settling delay.
export const POST_READY_FLUSH_DELAY_MS = 30
export const POST_READY_FLUSH_FALLBACK_MS = 200
@@ -8,58 +8,25 @@ export class PostReadyFlushGate {
private postDataTimer: ReturnType<typeof setTimeout> | null = null
private fallbackTimer: ReturnType<typeof setTimeout> | null = null
private generation = 0
private probePending = false
private probeDeadline = 0
constructor(
private readonly onFlush: () => void,
private readonly lineEditorProbe?: PtySlaveLineEditorProbe
private readonly markerIsLineEditorReady = false
) {}
private async flushWhenLineEditorReady(): Promise<void> {
const generation = this.generation
this.probePending = true
const state = await this.lineEditorProbe?.().catch(() => 'unknown')
if (generation !== this.generation) {
return
}
this.probePending = false
// Slow prompt hooks can leave cooked echo enabled long after the ready marker.
if (state === 'other' && Date.now() < this.probeDeadline) {
this.postDataTimer = setTimeout(() => {
this.postDataTimer = null
void this.flushWhenLineEditorReady()
}, 250)
return
}
this.onFlush()
}
private flush(): void {
if (this.lineEditorProbe) {
void this.flushWhenLineEditorReady()
} else {
this.onFlush()
}
}
/** True between arm() and the actual flush firing. Callers should treat
* input as still-queued during this window to preserve ordering. */
get isPending(): boolean {
return (
this.probePending ||
this.awaitingPromptDraw ||
this.postDataTimer !== null ||
this.fallbackTimer !== null
)
return this.awaitingPromptDraw || this.postDataTimer !== null || this.fallbackTimer !== null
}
/** Arm the gate after observing the shell-ready marker. Starts the
* wall-clock fallback unless the marker scan already observed post-marker
* bytes, in which case the short post-data settle path is enough. */
arm(postMarkerBytesObserved = false): void {
this.probeDeadline = Date.now() + 15_000
if (this.markerIsLineEditorReady) {
this.onFlush()
return
}
this.awaitingPromptDraw = true
if (postMarkerBytesObserved) {
this.notifyData()
@@ -68,7 +35,7 @@ export class PostReadyFlushGate {
this.fallbackTimer = setTimeout(() => {
this.fallbackTimer = null
this.awaitingPromptDraw = false
this.flush()
this.onFlush()
}, POST_READY_FLUSH_FALLBACK_MS)
}
@@ -87,15 +54,13 @@ export class PostReadyFlushGate {
if (this.postDataTimer === null) {
this.postDataTimer = setTimeout(() => {
this.postDataTimer = null
this.flush()
this.onFlush()
}, POST_READY_FLUSH_DELAY_MS)
}
}
/** Cancel any pending flush. Call on session teardown. */
clear(): void {
this.generation += 1
this.probePending = false
this.awaitingPromptDraw = false
if (this.postDataTimer) {
clearTimeout(this.postDataTimer)
@@ -1,3 +1,4 @@
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { win32 as pathWin32 } from 'node:path'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash'
import { isPwshAvailable } from '../../pwsh'
@@ -34,7 +35,11 @@ import {
} from '../../../shared/agent-process-recognition'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
import {
getShellLaunchConfig,
resolvePtyShellPath,
shellReadyMarkerComesFromLineEditor
} from '../shell-ready'
import { resolveWslSessionContext } from '../wsl-session-context'
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
import type { PtySubprocessOptions } from '../pty-subprocess'
@@ -188,8 +193,14 @@ export function createPtyShellLaunchPlan(
`[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"`
)
}
// PTY input must wait for the line editor, including plain Codex launches.
const waitsForShellReady = Boolean(opts.command)
const waitsForShellReady =
Boolean(opts.command) &&
(startupAgentRecognition?.agent !== 'codex' ||
shellReadyMarkerComesFromLineEditor(shellPath) ||
shouldUseShellReadyStartupDelivery({
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery
}))
delete env.ORCA_SHELL_FEATURES
const shellLaunch = getShellLaunchConfig(
shellPath,
@@ -1,4 +1,4 @@
import { createPtySlaveLineEditorProbe } from '../../shared/pty-slave-line-discipline-echo'
import { shellReadyMarkerComesFromLineEditor } from './shell-ready'
import {
installDeviceAttributesResponder,
STARTUP_DA1_RESPONSE
@@ -20,6 +20,7 @@ import { basename } from 'node:path'
import type { ShellReadyState } from './types'
const SHELL_READY_TIMEOUT_MS = 15_000
export const CODEX_SHELL_READY_TIMEOUT_MS = 300
export type SessionShellReadyBarrierDeps = {
sessionId: string
@@ -70,7 +71,7 @@ export class SessionShellReadyBarrier {
this.postReadyFlushGate = new PostReadyFlushGate(
() => this.flushPreReadyQueue(),
createPtySlaveLineEditorProbe(deps.subprocess.slavePath)
shellReadyMarkerComesFromLineEditor(deps.subprocess.shellPath ?? '')
)
}
+5
View File
@@ -101,6 +101,11 @@ export function resolvePtyShellPath(env: Record<string, string>): string {
return env.SHELL || process.env.SHELL || '/bin/zsh'
}
export function shellReadyMarkerComesFromLineEditor(shellPath: string): boolean {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
return shellName === 'bash' || shellName === 'zsh'
}
export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
// Why fish: markerless, its startup command is written before fish's reader owns