mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
fix(orchestration): take a foreground-proven launch without a dialog settle
With agent hooks off, a worker whose brief rode its launch line is judged by the agent holding its terminal. That verdict used to wait a further 2 s for a startup dialog to settle; it now counts as soon as the foreground read proves the agent and a direct read of the screen shows no startup dialog. The foreground read itself is unchanged.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LAUNCH_HOOK_SILENCE_MS,
|
||||
observeLaunchTurnStart,
|
||||
@@ -15,6 +15,7 @@ function probe(overrides: Partial<LaunchTurnStartProbe>): LaunchTurnStartProbe {
|
||||
observeHookTurn: pendingHookTurn,
|
||||
hookReachedPane: () => false,
|
||||
readWorkingSequence: () => 0,
|
||||
dialogOnScreen: () => false,
|
||||
launchRecorded: () => true,
|
||||
readForeground: async () => 'agent',
|
||||
...overrides
|
||||
@@ -74,4 +75,64 @@ describe('observeLaunchTurnStart', () => {
|
||||
)
|
||||
).resolves.toBe('unobserved')
|
||||
})
|
||||
|
||||
// Why pinned: a worker start with hooks off must be no slower than main, which settled on its
|
||||
// first evidence; a fixed wait here is the receipt arriving seconds after the brief.
|
||||
describe('with hooks off, on fake time', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
/** The verdict once `ms` of fake time has passed, or `pending`. */
|
||||
async function verdictAfter(observing: Promise<string>, ms: number): Promise<string> {
|
||||
let verdict = 'pending'
|
||||
void observing.then((value) => {
|
||||
verdict = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
return verdict
|
||||
}
|
||||
|
||||
// The foreground read is a process scan that can take seconds under load; it is the only wait.
|
||||
it('takes the agent proven in front as soon as the read answers, with no dialog on screen', async () => {
|
||||
vi.useFakeTimers()
|
||||
const readForeground = () =>
|
||||
new Promise<'agent'>((resolve) => setTimeout(() => resolve('agent'), 4_000))
|
||||
const observing = observeLaunchTurnStart(
|
||||
probe({ observeHookTurn: undefined, readForeground }),
|
||||
{ launchStartedAt: Date.now(), timeoutMs: 30_000 }
|
||||
)
|
||||
await expect(verdictAfter(observing, 3_999)).resolves.toBe('pending')
|
||||
await expect(verdictAfter(observing, 1)).resolves.toBe('unsupported')
|
||||
})
|
||||
|
||||
it('does not take the agent in front while a startup dialog is on screen', async () => {
|
||||
vi.useFakeTimers()
|
||||
let dialog = true
|
||||
const observing = observeLaunchTurnStart(
|
||||
probe({ observeHookTurn: undefined, dialogOnScreen: () => dialog }),
|
||||
{ launchStartedAt: Date.now(), timeoutMs: 30_000 }
|
||||
)
|
||||
await expect(verdictAfter(observing, 5_000)).resolves.toBe('pending')
|
||||
dialog = false
|
||||
await expect(verdictAfter(observing, 1_000)).resolves.toBe('unsupported')
|
||||
})
|
||||
})
|
||||
|
||||
it('waits the hook silence out, and no longer, when hooks are on but never report', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let verdict = 'pending'
|
||||
void observeLaunchTurnStart(probe({}), {
|
||||
launchStartedAt: Date.now(),
|
||||
timeoutMs: 30_000
|
||||
}).then((value) => {
|
||||
verdict = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(LAUNCH_HOOK_SILENCE_MS - 250)
|
||||
expect(verdict).toBe('pending')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(verdict).toBe('unsupported')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,8 @@ export type LaunchTurnStartProbe = {
|
||||
hookReachedPane: () => boolean
|
||||
/** The pane's title/lifecycle turn-start count: the evidence main accepted. */
|
||||
readWorkingSequence: () => number
|
||||
/** Whether a startup dialog is on the pane's screen now; read directly, so it needs no quiet. */
|
||||
dialogOnScreen: () => boolean
|
||||
/** Whether the launch command is still recorded on its PTY; the shell's command-finished retires it. */
|
||||
launchRecorded: () => boolean
|
||||
readForeground: () => Promise<LaunchedAgentForeground>
|
||||
@@ -35,8 +37,9 @@ export type LaunchTurnStartProbe = {
|
||||
*
|
||||
* The agent's hook is the proof wherever it can give one. A launch whose hooks never reach the pane
|
||||
* (turned off, or not installed on that host) is judged as main judged it: a title turn-start edge,
|
||||
* or else the agent holding its terminal, which carries the prompt on its command line. A launch
|
||||
* the shell reports finished, with no agent in front, exited at startup.
|
||||
* or else the agent holding its terminal with no startup dialog on screen, since it carries the
|
||||
* prompt on its command line. A launch the shell reports finished, with no agent in front, exited at
|
||||
* startup.
|
||||
*/
|
||||
export async function observeLaunchTurnStart(
|
||||
probe: LaunchTurnStartProbe,
|
||||
@@ -87,7 +90,8 @@ async function watchLaunchEvidence(
|
||||
}
|
||||
if (Date.now() >= nextForegroundReadAt) {
|
||||
nextForegroundReadAt = Date.now() + LAUNCH_FOREGROUND_READ_MS
|
||||
if ((await probe.readForeground()) === 'agent') {
|
||||
// A dialog on screen holds the agent; the caller's dialog watch reports it.
|
||||
if ((await probe.readForeground()) === 'agent' && !probe.dialogOnScreen()) {
|
||||
return 'unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
observeLaunchTurnStart,
|
||||
type LaunchTurnStartVerdict
|
||||
} from './launch-turn-start-observation'
|
||||
import { readFreshComposerHold } from './launched-agent-composer-readiness'
|
||||
|
||||
export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWithSerializeAgentPromptSubmission {
|
||||
private readonly agentPromptCorrelation = new AgentPromptRequestCorrelation()
|
||||
@@ -136,6 +137,11 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit
|
||||
: {}),
|
||||
hookReachedPane: () => this.getFreshExplicitAgentStatusForPty(handle, ptyId) !== null,
|
||||
readWorkingSequence: () => this.getAgentPromptActivity(handle, ptyId).workingSequence,
|
||||
dialogOnScreen: () =>
|
||||
readFreshComposerHold(
|
||||
this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText,
|
||||
this.readLiveTerminalScreenLines(ptyId)
|
||||
) === 'dialog',
|
||||
launchRecorded: () => Boolean(this.ptysById.get(ptyId)?.launchAgent),
|
||||
readForeground: async () =>
|
||||
agent ? await this.readLaunchedAgentForeground(ptyId, agent) : 'unknown'
|
||||
|
||||
@@ -122,6 +122,27 @@ describe('observeTerminalLaunchTurnStart', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: Claude's hook is its usual proof; with hooks turned off it must not wait out the silence.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'judges a Claude launch at once when hooks are turned off',
|
||||
async () => {
|
||||
const { runtime, handle } = await launchedCodex(() => [], {
|
||||
hooksEnabled: false,
|
||||
foreground: () => 'claude'
|
||||
})
|
||||
const startedAt = Date.now()
|
||||
|
||||
await expect(
|
||||
runtime.observeTerminalLaunchTurnStart(
|
||||
handle,
|
||||
{ launchStartedAt: startedAt, agent: 'claude' },
|
||||
5_000
|
||||
)
|
||||
).resolves.toBe('unsupported')
|
||||
expect(Date.now() - startedAt).toBeLessThan(1_000)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'reports a launch the shell finished, with the shell back in front, as exited',
|
||||
async () => {
|
||||
|
||||
@@ -151,21 +151,22 @@ describe('worker-start with the brief on the launch command line', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('is not ready while a trust dialog holds it', async () => {
|
||||
// Why pinned: the observer reads no dialog on screen before it says so; settling here again
|
||||
// put the receipt seconds behind main with hooks off.
|
||||
it('is ready at once, without waiting on the startup dialog watch', async () => {
|
||||
h.setup()
|
||||
vi.mocked(h.runtime.waitForTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
condition: 'tui-idle',
|
||||
satisfied: false,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
blockedReason: 'agent-trust-workspace'
|
||||
})
|
||||
await expect(startProvenByForeground('trust-blocked worker')).resolves.toMatchObject({
|
||||
state: 'outcome_unknown',
|
||||
stage: 'turn_start_blocked',
|
||||
lastError: expect.stringContaining('Agent startup blocked: agent-trust-workspace')
|
||||
})
|
||||
vi.mocked(h.runtime.waitForTerminal).mockReturnValue(new Promise(() => {}))
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
try {
|
||||
let receipt: unknown = 'pending'
|
||||
void startProvenByForeground('fast worker').then((value) => {
|
||||
receipt = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(receipt).toMatchObject({ state: 'ready', turnStart: 'unsupported' })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,8 +97,6 @@ export function describeUnobservedWorkerTurnStart(agent: string | null): string
|
||||
|
||||
/** Pause before re-arming a dialog watch that found the agent idle; an idle screen re-reads instantly. */
|
||||
const STARTUP_DIALOG_REWATCH_MS = 500
|
||||
/** Past the 1.5 s quiet a dialog needs to read as one, so a dialog already painting is caught. */
|
||||
const STARTUP_DIALOG_SETTLE_MS = 2_000
|
||||
|
||||
/**
|
||||
* Turn-start verdict for a worker whose brief rode its launch command line. The agent's hook turn
|
||||
@@ -139,14 +137,10 @@ export async function observeWorkerLaunchTurnStart(args: {
|
||||
if (verdict === 'exited') {
|
||||
throw new Error('Agent exited before its first turn started; the shell is back in front.')
|
||||
}
|
||||
if (verdict === 'observed') {
|
||||
// `unsupported` already read no startup dialog on screen, so neither waits for one to settle.
|
||||
if (verdict === 'observed' || verdict === 'unsupported') {
|
||||
return { verdict }
|
||||
}
|
||||
if (verdict === 'unsupported') {
|
||||
// Why: an agent proven in front can still be held by a dialog that has not settled on screen.
|
||||
const blockedReason = await settledWithin(dialog, STARTUP_DIALOG_SETTLE_MS)
|
||||
return blockedReason ? blockedLaunchObservation(blockedReason) : { verdict }
|
||||
}
|
||||
// Why: a dialog already on screen outranks any verdict short of an observed turn.
|
||||
const blockedReason = await settledOrNull(dialog)
|
||||
if (blockedReason) {
|
||||
@@ -229,17 +223,3 @@ async function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
async function settledOrNull<T>(promise: Promise<T | null>): Promise<T | null> {
|
||||
return await Promise.race([promise, Promise.resolve(null)])
|
||||
}
|
||||
|
||||
async function settledWithin<T>(promise: Promise<T | null>, ms: number): Promise<T | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), ms)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user