diff --git a/packages/contracts/src/wait.ts b/packages/contracts/src/wait.ts index 6ba07ad602..9528c11f22 100644 --- a/packages/contracts/src/wait.ts +++ b/packages/contracts/src/wait.ts @@ -6,6 +6,9 @@ * `wait_capture_stalled` means no readable capture established an observation * before the deadline and is retriable. `wait_deadline_exceeded` means a later * capture consumed the remaining budget after at least one readable capture. + * `wait_readiness_exhausted` means the deadline cancelled a poll that was still + * making the target observable; `details.readinessPhase` names the work, and + * `wait_runner_restart_exhausted` is the same verdict for a runner restart. * `wait_target_absent` is the ordinary timeout reason for a selector that was * never found. `wait_target_present` is the strict-absence timeout reason when * valid captures still contain matches. The remaining reasons describe @@ -15,6 +18,7 @@ export const WAIT_REASONS = { captureStalled: 'wait_capture_stalled', deadlineExceeded: 'wait_deadline_exceeded', runnerRestartExhausted: 'wait_runner_restart_exhausted', + readinessExhausted: 'wait_readiness_exhausted', targetAbsent: 'wait_target_absent', targetPresent: 'wait_target_present', stableTimeout: 'wait_stable_timeout', @@ -23,6 +27,22 @@ export const WAIT_REASONS = { export type WaitReason = (typeof WAIT_REASONS)[keyof typeof WAIT_REASONS]; +/** + * Work a platform does before it can observe the target at all: starting the Apple XCTest runner, + * or discovering the Simulator app process the host AX bridge reads. Only the code doing that work + * at the moment of cancellation names it, as `details.readinessPhase` on the error it throws. + */ +const READINESS_PHASES = ['runner-start', 'target-discovery'] as const; + +export type ReadinessPhase = (typeof READINESS_PHASES)[number]; + +export function readinessPhaseOf( + details: Readonly> | undefined, +): ReadinessPhase | undefined { + const phase = details?.readinessPhase; + return READINESS_PHASES.find((candidate) => candidate === phase); +} + /** * Public daemon result for `wait`. The runtime-local result carries a `kind` * discriminant, but `toDaemonWaitData` intentionally projects the normal daemon diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index b71fe25732..ca7ce87fa5 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; @@ -220,6 +222,66 @@ test('a failed restart preserves the invalidated runner evidence', async () => { } }); +// A runner that has not answered yet is readiness work, not observation: a caller whose deadline +// lands there needs to know the start consumed it, so the cancellation names the phase (#2343). +test('a cancellation during a runner start names the start as the readiness phase', async () => { + const unanswered = await startFakeRunnerServer([]); + await unanswered.close(); + const starting = { ...makeRunnerSession(unanswered.port), state: 'starting' as const }; + ensureRunnerSessionMock.mockResolvedValue(starting); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + await expect( + runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }, { signal: controller.signal }), + ).rejects.toMatchObject({ + details: { reason: 'request_canceled', readinessPhase: 'runner-start' }, + }); + expect(invalidateRunnerSessionMock).toHaveBeenCalledWith( + starting, + 'runner_startup_request_canceled', + ); +}); + +test('a cancellation before any runner session exists names the start as the readiness phase', async () => { + ensureRunnerSessionMock.mockImplementation( + async (_device: unknown, options: { signal?: AbortSignal }) => + await new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => reject(createRequestCanceledError()), { + once: true, + }); + }), + ); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 20); + + await expect( + runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }, { signal: controller.signal }), + ).rejects.toMatchObject({ details: { readinessPhase: 'runner-start' } }); +}); + +test('a cancellation of a command on a ready runner is not readiness work', async () => { + const silent = http.createServer(() => {}); + await new Promise((resolve) => silent.listen(0, '127.0.0.1', resolve)); + try { + seedSession((silent.address() as AddressInfo).port); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + const command = runAppleRunnerCommand( + IOS_SIMULATOR, + { command: 'snapshot' }, + { signal: controller.signal }, + ); + + await expect(command).rejects.toMatchObject({ details: { reason: 'request_canceled' } }); + await expect(command).rejects.not.toHaveProperty('details.readinessPhase'); + } finally { + silent.closeAllConnections(); + await new Promise((resolve) => silent.close(() => resolve())); + } +}); + test('an exact-session command never dispatches to a replacement runner', async () => { server = await startFakeRunnerServer({ recordStop: [{ kind: 'ok', data: {} }] }); const replacement = seedSession(server.port); diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 1cb4884a6f..5d50e464e9 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -1,5 +1,11 @@ -import { AppError, asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import { + AppError, + asAppError, + createRequestCanceledError, + isRequestCanceledError, +} from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ReadinessPhase } from '@agent-device/contracts/wait'; import { emitDiagnostic } from './host.ts'; import { RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts'; import { RUNNER_COMMAND_TIMEOUT_MS } from './runner-transport.ts'; @@ -265,15 +271,15 @@ export async function executeRunnerCommand( const recycleKey = runnerRecycleLedgerKey(options, command); let session: RunnerSession | undefined; let recycleBootBegun = false; + const livenessAtEntry = readRunnerSessionLiveness(device.id)?.liveness ?? 'gone'; try { // A request that already used a runner session and finds no runner process is about to pay // for a recycle boot (~25s): bound that to the per-request recycle budget so a hostile screen // fails fast with a preserved session instead of stacking runner boots (#1105). // `gone` and `stopped` are the two liveness answers that mean no runner is answering now, so // this command is the one that would start a process (#2662). - const liveness = readRunnerSessionLiveness(device.id)?.liveness ?? 'gone'; if ( - (liveness === 'gone' || liveness === 'stopped') && + (livenessAtEntry === 'gone' || livenessAtEntry === 'stopped') && hasRunnerRequestTouchedSession(recycleKey) ) { if (!tryBeginRunnerRecycle(recycleKey)) { @@ -301,9 +307,17 @@ export async function executeRunnerCommand( } catch (error) { if (options.expectedRunnerSessionId !== undefined) throw error; const appErr = asAppError(error, 'COMMAND_FAILED'); - if (session && session.state === 'starting' && isRequestCanceledError(appErr)) { - await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled'); - throw error; + const runnerNeverAnswered = session + ? session.state === 'starting' + : livenessAtEntry !== 'ready'; + if (runnerNeverAnswered && isRequestCanceledError(appErr)) { + if (session) { + await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled'); + } + throw createRequestCanceledError( + { ...appErr.details, readinessPhase: 'runner-start' satisfies ReadinessPhase }, + appErr, + ); } if (shouldRestartRunnerBeforeCommandSend(appErr) && session) { assertRunnerRequestActive(options.requestId); diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index d1be189ac7..fc92a2be74 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -1,5 +1,6 @@ import { expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createRequestCanceledError } from '@agent-device/kernel/errors'; // Keep the route tests hermetic: the default system-surface presence probe shells out to `ps`, which // never resolves under the fake timers these tests drive. Tests that exercise the bypass inject @@ -783,3 +784,130 @@ test('a slow app discovery keeps observation on the bridge while no runner can a vi.useRealTimers(); } }); + +test.for(['rejects', 'exits'] as const)( + 'a deadline during the cached target re-check is not readiness work (ps %s)', + async (psOnAbort) => { + // A known target is re-checked with one `ps` per capture; no discovery runs. A deadline that + // lands there must stay a plain cancellation, or a wait would report readiness exhaustion over + // evidence its earlier polls already gathered (#2343 review). + const run = vi.fn(async (args: readonly string[]) => ({ + stdout: + args[0] === 'spawn' + ? `42\t0\tUIKitApplication:${input.options.appBundleId}[launch-a][rb-legacy]` + : JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] }, + }), + stderr: '', + exitCode: 0, + })); + let recheckStarted!: () => void; + const recheck = new Promise((resolve) => { + recheckStarted = resolve; + }); + let psCalls = 0; + const runCommand = vi.fn( + async (cmd: string, args: readonly string[], options?: { signal?: AbortSignal }) => { + if (cmd !== 'ps' || ++psCalls === 1) return { stdout: 'start-a', stderr: '', exitCode: 0 }; + recheckStarted(); + return await new Promise<{ stdout: string; stderr: string; exitCode: number }>( + (resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => + psOnAbort === 'rejects' + ? reject(createRequestCanceledError({ cmd, args })) + : resolve({ stdout: '', stderr: '', exitCode: 1 }), + { once: true }, + ); + }, + ); + }, + ); + const fallback = vi.fn(async () => runnerResult()); + const baseHost = platformRuntimeHostFixture(); + const route = createAppleSnapshotRoute( + { + ...baseHost, + appleApplications: { + ...baseHost.appleApplications, + hasLiveRunnerSession: async () => false, + }, + snapshot: { + captureSurface: vi.fn(), + presentIosAcquisition: vi.fn(async () => ({ + backend: 'xctest' as const, + producer: 'simulator-ax-bridge' as const, + nodes: [{ index: 0, type: 'Application' }], + })), + }, + }, + { + source: sourceReturning(bridgeAcquisition()), + resolveTarget: createSimulatorSnapshotTargetResolver(), + }, + ); + await withAppleToolProvider( + createLocalAppleToolProvider({ simctl: { run }, runCommand }), + async () => { + await route.capture(ios, input, signal(), fallback); + const deadline = new AbortController(); + const capture = route.capture(ios, input, deadline.signal, fallback); + await recheck; + deadline.abort(new DOMException('Wait deadline exceeded', 'TimeoutError')); + + await expect(capture).rejects.not.toHaveProperty('details.readinessPhase'); + expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1); + expect(fallback).not.toHaveBeenCalled(); + }, + ); + }, +); + +test('a deadline during a slow app discovery names the discovery as the readiness phase', async () => { + // The capture never reached the bridge or the runner: its whole cost was finding the target, so + // the cancellation says so instead of reading as a capture that produced nothing (#2343). + const run = vi.fn(async (args: readonly string[]) => { + if (args[0] === 'spawn') await new Promise(() => {}); + return { + stdout: JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] }, + }), + stderr: '', + exitCode: 0, + }; + }); + const runCommand = vi.fn(async () => ({ stdout: 'start-a', stderr: '', exitCode: 0 })); + const fallback = vi.fn(async () => runnerResult()); + const baseHost = platformRuntimeHostFixture(); + const route = createAppleSnapshotRoute( + { + ...baseHost, + appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession: async () => false }, + }, + { + source: sourceReturning(bridgeAcquisition()), + resolveTarget: createSimulatorSnapshotTargetResolver(), + }, + ); + const deadline = new AbortController(); + vi.useFakeTimers(); + try { + await withAppleToolProvider( + createLocalAppleToolProvider({ simctl: { run }, runCommand }), + async () => { + const capture = route.capture(ios, input, deadline.signal, fallback); + const settled = expect(capture).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'request_canceled', readinessPhase: 'target-discovery' }, + }); + await vi.advanceTimersByTimeAsync(3_000); + deadline.abort(new DOMException('Wait deadline exceeded', 'TimeoutError')); + await settled; + expect(fallback).not.toHaveBeenCalled(); + }, + ); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 9bed7d9eb6..526b6dbe69 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -16,7 +16,7 @@ import { deriveIosCaptureHint, } from '@agent-device/capture-kit/ios-snapshot-planning'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createSimulatorSnapshotSource, @@ -140,7 +140,7 @@ export function createAppleSnapshotRoute( try { target = await resolveTargetForObservation(host, resolveTarget, device, input, signal); } catch (error) { - signal.throwIfAborted(); + rethrowIfResolutionCancelled(signal, error); emitRouteDiagnostic('target-resolution-failed', device, undefined, error); return await runFallback( device.id, @@ -247,6 +247,14 @@ async function resolveTargetForObservation( } } +/** + * A cancelled target resolution rethrows the resolver's own cancellation, which names the readiness + * phase only when the resolver was waiting on a running discovery. + */ +function rethrowIfResolutionCancelled(signal: AbortSignal, error: unknown): void { + if (signal.aborted) throw isRequestCanceledError(error) ? error : signal.reason; +} + function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean { return ( device.platform === 'apple' && diff --git a/packages/platform-apple/src/snapshot-target.test.ts b/packages/platform-apple/src/snapshot-target.test.ts index 09093a9c23..bb6ea0a1a8 100644 --- a/packages/platform-apple/src/snapshot-target.test.ts +++ b/packages/platform-apple/src/snapshot-target.test.ts @@ -217,8 +217,14 @@ test('a cancelled caller leaves discovery running for the next capture', async ( await withAppleToolProvider(fixture.provider, async () => { const controller = new AbortController(); const cancelled = fixture.resolve(ios, app, controller.signal); - controller.abort(new Error('request-ended')); - await expect(cancelled).rejects.toThrow('request-ended'); + const reason = new Error('request-ended'); + controller.abort(reason); + // The caller spent its time waiting on discovery, so its cancellation names that readiness + // work (#2343) and keeps the caller's own reason as the cause. + await expect(cancelled).rejects.toMatchObject({ + details: { reason: 'request_canceled', readinessPhase: 'target-discovery' }, + cause: reason, + }); release(); expect(await fixture.resolve(ios, app, signal())).toMatchObject({ pid: 42 }); @@ -226,6 +232,24 @@ test('a cancelled caller leaves discovery running for the next capture', async ( }); }); +test('a cancelled re-check of a known target keeps it and names no readiness work', async () => { + const fixture = targetFixture(); + await withAppleToolProvider(fixture.provider, async () => { + await fixture.resolve(ios, app, signal()); + const controller = new AbortController(); + const reason = new Error('wait-deadline'); + fixture.runCommand.mockImplementationOnce(async () => { + controller.abort(reason); + return { stdout: '', stderr: '', exitCode: 1 }; + }); + + await expect(fixture.resolve(ios, app, controller.signal)).rejects.toBe(reason); + + expect(await fixture.resolve(ios, app, signal())).toMatchObject({ pid: 42 }); + expect(fixture.discoveryCount()).toBe(1); + }); +}); + test('one discovery shares a single deadline across its simctl probes and the identity read', async () => { const fixture = targetFixture(); const release = deferredSpawn(fixture); diff --git a/packages/platform-apple/src/snapshot-target.ts b/packages/platform-apple/src/snapshot-target.ts index dfca5792c5..6da124b762 100644 --- a/packages/platform-apple/src/snapshot-target.ts +++ b/packages/platform-apple/src/snapshot-target.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import type { ReadinessPhase } from '@agent-device/contracts/wait'; import { createDetachedAttempts, waitForDetachedAttempt } from './detached-attempt.ts'; import { readSimctlDevicesByRuntime, @@ -57,6 +58,7 @@ export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTarget signal, timeoutMs: TARGET_IDENTITY_TIMEOUT_MS, }); + signal.throwIfAborted(); if (observed === cached.processStartTime) return cached; } targets.delete(key); @@ -67,12 +69,25 @@ export function createSimulatorSnapshotTargetResolver(): SimulatorSnapshotTarget return target; }, wait: (waitMs, stop) => - waitForDetachedAttempt({ waitMs, signal, stop, cancelled: () => signal.reason }), + waitForDetachedAttempt({ + waitMs, + signal, + stop, + cancelled: () => discoveryCancelled(signal), + }), pending: () => targetError(TARGET_DISCOVERY_PENDING, device, appBundleId), }); }; } +/** A caller cancelled while it waited on a running discovery: its time went to readiness work. */ +function discoveryCancelled(signal: AbortSignal): AppError { + return createRequestCanceledError( + { readinessPhase: 'target-discovery' satisfies ReadinessPhase }, + signal.reason, + ); +} + /** * Whether a resolver failure only says the discovery is still running. The discovery keeps going * under its own deadline, so asking again joins it rather than starting another. diff --git a/src/commands/interaction/runtime/wait-absent.ts b/src/commands/interaction/runtime/wait-absent.ts index afcd08970f..4c8a667a99 100644 --- a/src/commands/interaction/runtime/wait-absent.ts +++ b/src/commands/interaction/runtime/wait-absent.ts @@ -22,6 +22,7 @@ import type { } from './selector-wait.ts'; import { createWaitPolling, + isSelfReportedWaitDeadline, type WaitFailureEvidence, waitTimeoutError, type WaitPollDeadline, @@ -88,10 +89,10 @@ export async function waitForAbsent( await polling.sleepUntilNextPoll(); } - // A runner restart is the authoritative deadline cause even when an earlier - // readable poll saw the target. Returning stale target-present evidence would - // hide the retriable restart and make callers stop retrying the wrong reason. - if (deadline === 'runner-restart-exhausted') { + // A self-reported deadline cause is authoritative even when an earlier readable + // poll saw the target. Returning stale target-present evidence would hide the + // retriable cause and make callers stop retrying for the wrong reason. + if (isSelfReportedWaitDeadline(deadline)) { throw waitTimeoutError( `wait absent timed out for selector: ${selectorExpression}`, polling, diff --git a/src/commands/interaction/runtime/wait-polling.ts b/src/commands/interaction/runtime/wait-polling.ts index ea7d5655e4..e45cd778ca 100644 --- a/src/commands/interaction/runtime/wait-polling.ts +++ b/src/commands/interaction/runtime/wait-polling.ts @@ -1,5 +1,10 @@ import { AppError, asAppError, type AppErrorDetails } from '@agent-device/kernel/errors'; -import { WAIT_REASONS, type WaitReason } from '@agent-device/contracts/wait'; +import { + readinessPhaseOf, + WAIT_REASONS, + type ReadinessPhase, + type WaitReason, +} from '@agent-device/contracts/wait'; import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; import { selectorPollBudget } from '@agent-device/selectors/selector-pipeline'; import { @@ -15,20 +20,25 @@ import { runWithinWaitDeadline } from './wait-deadline.ts'; */ export const DEFAULT_WAIT_TIMEOUT_MS = SELECTOR_PIPELINE_POLICIES.wait.poll.defaultTimeoutMs; -export type WaitPollDeadline = 'capture-stalled' | 'capture-truncated' | 'runner-restart-exhausted'; +export type WaitPollDeadline = + | 'capture-stalled' + | 'capture-truncated' + | 'runner-restart-exhausted' + | 'readiness-exhausted'; /** * How one poll ended: a readable capture, an unreadable content verdict the wait rode out, a * producer refusal the wait rode out because the producer itself classified it as retriable, the - * deadline cancelling the capture in flight, or that cancellation carrying runner-restart - * evidence. Whether a readable capture matched is the caller's verdict, not the poll's. + * deadline cancelling the capture in flight, or that cancellation landing in a runner restart or + * in readiness work. Whether a readable capture matched is the caller's verdict, not the poll's. */ export type WaitPollOutcome = | 'readable' | 'unreadable' | 'retriable' | 'deadline' - | 'runner-restart'; + | 'runner-restart' + | 'readiness'; /** One poll on the wait's own clock: when it started after the wait began and how long it ran. */ export type WaitPollRecord = { @@ -54,6 +64,7 @@ export type WaitFailureEvidence = { runnerRestartCommandId?: string; runnerInvalidatedSessionId?: string; runnerRestartSessionId?: string; + readinessPhase?: ReadinessPhase; logPath?: string; diagnosticId?: string; }; @@ -139,15 +150,12 @@ export function createWaitPolling( recordPoll(completedPollOutcome(captureWasReadable, unobserved.lastUnobservedCause())); return result; } - const runnerRestart = runnerRestartTimeoutEvidence(result.error); - recordPoll(runnerRestart ? 'runner-restart' : 'deadline'); - timeoutEvidence = runnerRestart ?? {}; + const cancelled = cancelledPoll(result.error, unobserved.readableCaptures()); + recordPoll(cancelled.outcome); + timeoutEvidence = cancelled.evidence; // A capture that only becomes readable after its deadline is not evidence for this wait. // Count only captures that completed before runWithinWaitDeadline returned a timeout. - return { - timedOut: true as const, - deadline: timedOutDeadline(runnerRestart !== undefined, unobserved.readableCaptures()), - }; + return { timedOut: true as const, deadline: cancelled.deadline }; }, hasTimeRemaining: () => remainingMs() > 0, failureEvidence: (): WaitFailureEvidence => ({ @@ -177,14 +185,37 @@ function completedPollOutcome( } /** - * Why the deadline, not the target, ended the wait. A poll is a backend stall when no completed + * Why the deadline, not the target, ended the wait. A cancellation that landed in a runner restart + * or in readiness work says so itself. Otherwise a poll is a backend stall when no completed * capture established a readable observation; the poll index is not evidence. This remains true * after one or more unreadable content verdicts followed by a capture that consumes the remaining * budget. */ -function timedOutDeadline(runnerRestarted: boolean, readableCaptures: number): WaitPollDeadline { - if (runnerRestarted) return 'runner-restart-exhausted'; - return readableCaptures === 0 ? 'capture-stalled' : 'capture-truncated'; +function cancelledPoll( + error: unknown, + readableCaptures: number, +): { + outcome: WaitPollOutcome; + deadline: WaitPollDeadline; + evidence: Partial; +} { + const runnerRestart = runnerRestartTimeoutEvidence(error); + if (runnerRestart) { + return { + outcome: 'runner-restart', + deadline: 'runner-restart-exhausted', + evidence: runnerRestart, + }; + } + const readinessPhase = readinessPhaseOf(error instanceof AppError ? error.details : undefined); + if (readinessPhase) { + return { outcome: 'readiness', deadline: 'readiness-exhausted', evidence: { readinessPhase } }; + } + return { + outcome: 'deadline', + deadline: readableCaptures === 0 ? 'capture-stalled' : 'capture-truncated', + evidence: {}, + }; } function compactPollTimeline(polls: readonly WaitPollRecord[]): WaitPollRecord[] { @@ -212,6 +243,15 @@ function waitRunnerRestartExhaustedError(message: string, evidence: WaitFailureE }); } +function waitReadinessExhaustedError(message: string, evidence: WaitFailureEvidence): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: WAIT_REASONS.readinessExhausted, + ...evidence, + retriable: true, + hint: `The wait timeout ended during readiness work (${evidence.readinessPhase}), before that poll could capture the screen. Retry with a timeout long enough to cover it.`, + }); +} + function waitDeadlineExceededError(message: string, evidence: WaitFailureEvidence): AppError { return new AppError('COMMAND_FAILED', message, { reason: WAIT_REASONS.deadlineExceeded, @@ -276,21 +316,42 @@ export function waitTimeoutError( deadline: WaitPollDeadline | undefined, ): AppError { const evidence = polling.failureEvidence(); - if (deadline === 'runner-restart-exhausted') { - return waitRunnerRestartExhaustedError(message, evidence); + switch (deadline) { + case 'runner-restart-exhausted': + return waitRunnerRestartExhaustedError(message, evidence); + case 'readiness-exhausted': + // A refusal an earlier poll hit (the app is not running, say) is an answer the readiness + // work on the final poll does not replace. + rethrowNeverReadableCause(polling, evidence, false); + return waitReadinessExhaustedError(message, evidence); + case 'capture-stalled': + // Whether a content verdict outranks the stall verdict is the caller's policy; a refusal is + // preserved either way. + rethrowNeverReadableCause(polling, evidence, polling.preserveUnreadableOnStall === true); + return waitCaptureStalledError(message, evidence); + case 'capture-truncated': + return waitDeadlineExceededError(message, evidence); + case undefined: + rethrowNeverReadableCause(polling, evidence, true); + return evidence.readableCaptures === 0 + ? waitCaptureStalledError(message, evidence) + : waitTargetAbsentError(message, evidence); + default: + return assertNever(deadline); } - if (deadline === 'capture-stalled') { - // Whether a content verdict outranks the stall verdict is the caller's policy; a refusal is - // preserved either way. - rethrowNeverReadableCause(polling, evidence, polling.preserveUnreadableOnStall === true); - return waitCaptureStalledError(message, evidence); - } - if (deadline === 'capture-truncated') return waitDeadlineExceededError(message, evidence); +} + +/** + * Whether the deadline's cause was reported by the work it cancelled — a runner restart or + * readiness work — rather than inferred from the captures. Such a cause outranks evidence an + * earlier readable capture left behind. + */ +export function isSelfReportedWaitDeadline(deadline: WaitPollDeadline | undefined): boolean { + return deadline === 'runner-restart-exhausted' || deadline === 'readiness-exhausted'; +} - rethrowNeverReadableCause(polling, evidence, true); - return evidence.readableCaptures === 0 - ? waitCaptureStalledError(message, evidence) - : waitTargetAbsentError(message, evidence); +function assertNever(value: never): never { + throw new Error(`Unhandled wait deadline: ${String(value)}`); } function runnerRestartTimeoutEvidence(error: unknown): Partial | undefined { diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index fe84f4fa1c..6fab968aba 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; -import { WAIT_REASONS } from '@agent-device/contracts/wait'; -import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { type DeviceBinding, type RuntimeFacts, @@ -743,6 +743,142 @@ test('strict wait absent does not mask a runner restart after an earlier present expect(response.error.details?.reason).not.toBe(WAIT_REASONS.targetPresent); }); +/** + * The platform's cancellation as production throws it when the wait deadline lands mid-capture. + * With a phase, the runner start or the Simulator app discovery was still running; without one, + * the capture was cancelled in steady-state work such as the cached target re-check. + */ +function cancelledCapture(phase: ReadinessPhase | undefined, beforeStall: SnapshotResult[] = []) { + const readable = [...beforeStall]; + return vi.fn(async (input: CaptureSnapshotInput) => { + const next = readable.shift(); + if (next) return next; + const signal = input.signal; + if (!signal) throw new Error('the poll deadline never reached the platform'); + await new Promise((resolve) => { + if (signal.aborted) return resolve(); + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + throw createRequestCanceledError(phase ? { readinessPhase: phase } : {}, signal.reason); + }); +} + +test.for(['runner-start', 'target-discovery'] as const)( + 'a %s that outlasts the wait reports readiness exhaustion, not a capture stall', + async (phase) => { + const harness = waitRuntimeHarness({ captureSnapshot: cancelledCapture(phase) }); + + const { response } = await runWait(['text', 'Ready', '50'], harness); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.details).toMatchObject({ + reason: WAIT_REASONS.readinessExhausted, + readinessPhase: phase, + retriable: true, + readableCaptures: 0, + captures: 1, + polls: [{ startedMs: 0, outcome: 'readiness' }], + }); + expect(response.error.details?.captureStalled).toBeUndefined(); + }, +); + +test('strict wait absent reports readiness exhaustion over an earlier present capture', async () => { + const captureSnapshot = cancelledCapture('target-discovery', [ + { + nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], + backend: 'web', + producer: 'agent-browser', + }, + ]); + const harness = waitRuntimeHarness({ captureSnapshot }); + + const { response } = await runWait(['absent', 'label="Ready"', '800'], harness); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.details).toMatchObject({ + reason: WAIT_REASONS.readinessExhausted, + readinessPhase: 'target-discovery', + readableCaptures: 1, + }); +}); + +test('an earlier retriable refusal outranks readiness work on the final poll', async () => { + // Live on iOS: the runner answered APP_NOT_RUNNING, then the next poll's app discovery was cut by + // the deadline. The refusal is the actionable answer; readiness stays in the evidence. + const notRunning = new AppError('COMMAND_FAILED', "app 'com.example.app' is not running", { + runnerErrorCode: 'APP_NOT_RUNNING', + retriable: true, + }); + let poll = 0; + const readiness = cancelledCapture('target-discovery'); + const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => { + if (poll++ === 0) throw notRunning; + return await readiness(input); + }); + const harness = waitRuntimeHarness({ captureSnapshot }); + + const { response } = await runWait(['text', 'Ready', '800'], harness); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.message).toContain('is not running'); + expect(response.error.details).toMatchObject({ + reason: WAIT_REASONS.captureStalled, + runnerErrorCode: 'APP_NOT_RUNNING', + readinessPhase: 'target-discovery', + readableCaptures: 0, + }); + expect(response.error.details?.polls).toMatchObject([ + { outcome: 'retriable' }, + { outcome: 'readiness' }, + ]); +}); + +test('strict wait absent keeps its present evidence when a steady-state capture is cancelled', async () => { + const captureSnapshot = cancelledCapture(undefined, [ + { + nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], + backend: 'web', + producer: 'agent-browser', + }, + ]); + const harness = waitRuntimeHarness({ captureSnapshot }); + + const { response } = await runWait(['absent', 'label="Ready"', '800'], harness); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.details).toMatchObject({ + reason: WAIT_REASONS.targetPresent, + readableCaptures: 1, + }); + expect(response.error.details?.readinessPhase).toBeUndefined(); +}); + +test('a positive wait whose steady-state capture is cancelled reports the deadline, not readiness', async () => { + const captureSnapshot = cancelledCapture(undefined, [ + { + nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }], + backend: 'web', + producer: 'agent-browser', + }, + ]); + const harness = waitRuntimeHarness({ captureSnapshot }); + + const { response } = await runWait(['text', 'Ready', '800'], harness); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.details).toMatchObject({ + reason: WAIT_REASONS.deadlineExceeded, + readableCaptures: 1, + }); + expect(response.error.details?.readinessPhase).toBeUndefined(); +}); + test('a readable capture that lacks the target stays target-absent, not capture-stalled', async () => { const harness = waitRuntimeHarness({ nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e5b2974062..8a74a11cbb 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -442,8 +442,8 @@ agent-device alert dismiss - `wait @ref` resolves the ref to its label/text from that stored snapshot, then polls for that text; it does not track the original node identity. - Because `wait @ref` is text-based after resolution, duplicate labels can match a different element than the original ref target. - `wait` shares the selector/snapshot resolution flow used by `click`, `fill`, `get`, and `is`. -- Wait failures carry a structured `error.details.reason` in `--json` output: `wait_target_absent` proves a positive wait never found a match; `wait_target_present` means strict `wait absent` reached its deadline with valid captures that still contained matches; `predicate_failed` means strict `wait absent` could not prove absence because no valid capture arrived, with the final observation/diagnostic preserved; `wait_capture_stalled` means no readable capture arrived and is retriable; `wait_deadline_exceeded` means a later capture consumed the remaining budget after an earlier readable capture; `wait_landmark_identity_mismatch` is a replay destination-guard refusal; and `wait_stable_timeout` means the UI did not settle. Use `readableCaptures`, `waitedMs`, `matches`, and `firstMatch` instead of parsing error text. `firstMatch` carries identity/text evidence only; absence failures do not claim visibility or rect evidence. -- Polling wait timeouts (`wait `, `wait text`, `wait @ref`, and `wait absent` once a readable capture has been seen) also carry `captures` (every poll attempted), `readableCaptures`, and `polls`, one entry per poll with `startedMs` on the wait's own clock, `durationMs`, and `outcome` (`readable`, `unreadable`, `retriable` for a poll the producer refused with a failure it marked retriable, `deadline`, or `runner-restart`), so a timeout says where its budget went; long waits keep the first five and last twenty-five polls. A replayed selector wait refused for a recorded landmark mismatch (`wait_landmark_identity_mismatch`) carries the same poll evidence next to its mismatch details. A wait that never saw a readable capture reports the cause its polls hit instead of a generic timeout: a content verdict is preserved as its producer wrote it, while a refusal the producer marked retriable keeps its code, message and retry details **and** carries the poll evidence above, so an exhausted budget stays distinguishable from a single immediate refusal. `wait --stable` timeouts and a never-readable strict absence keep their own diagnostics. `logPath` links the full request log. +- Wait failures carry a structured `error.details.reason` in `--json` output: `wait_target_absent` proves a positive wait never found a match; `wait_target_present` means strict `wait absent` reached its deadline with valid captures that still contained matches; `predicate_failed` means strict `wait absent` could not prove absence because no valid capture arrived, with the final observation/diagnostic preserved; `wait_capture_stalled` means no readable capture arrived and is retriable; `wait_deadline_exceeded` means a later capture consumed the remaining budget after an earlier readable capture; `wait_readiness_exhausted` means the deadline ended a poll that was still getting the iOS runner to answer its first command or finding the Simulator app, named by `readinessPhase` (`runner-start` or `target-discovery`), so a retry needs a timeout that covers that work; `wait_landmark_identity_mismatch` is a replay destination-guard refusal; and `wait_stable_timeout` means the UI did not settle. Use `readableCaptures`, `waitedMs`, `matches`, and `firstMatch` instead of parsing error text. `firstMatch` carries identity/text evidence only; absence failures do not claim visibility or rect evidence. +- Polling wait timeouts (`wait `, `wait text`, `wait @ref`, and `wait absent` once a readable capture has been seen) also carry `captures` (every poll attempted), `readableCaptures`, and `polls`, one entry per poll with `startedMs` on the wait's own clock, `durationMs`, and `outcome` (`readable`, `unreadable`, `retriable` for a poll the producer refused with a failure it marked retriable, `deadline`, `runner-restart`, or `readiness`), so a timeout says where its budget went; long waits keep the first five and last twenty-five polls. A replayed selector wait refused for a recorded landmark mismatch (`wait_landmark_identity_mismatch`) carries the same poll evidence next to its mismatch details. A wait that never saw a readable capture reports the cause its polls hit instead of a generic timeout: a content verdict is preserved as its producer wrote it, while a refusal the producer marked retriable keeps its code, message and retry details **and** carries the poll evidence above, so an exhausted budget stays distinguishable from a single immediate refusal. `wait --stable` timeouts and a never-readable strict absence keep their own diagnostics. `logPath` links the full request log. - `alert` inspects or handles system alerts on iOS simulator, macOS desktop, and Android native/runtime permission dialogs. - `alert` without an action is equivalent to `alert get`. - `accept` and `dismiss` are sent once on every platform. A lost or unconfirmed response is reported as an error and never replayed; run `alert get` before acting again.