diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index ecf4f6a764..d3d23329a4 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -1,4 +1,13 @@ export const ALERT_POLL_INTERVAL_MS = 300; +/** + * The floor for a retry read through the XCTest runner. After a read has completed, the alert + * loops start no further read with this much budget or less left: a read cut off by the runner's + * main-thread watchdog is reported as `MAIN_THREAD_TIMEOUT`, not absence, and live reads that + * saw a late prompt needed up to about 1.4 s. It gates retry reads only. The first read starts + * whatever the window, including a caller `timeoutMs` below this value, and the macOS helper + * keeps the poll interval instead. + */ +export const ALERT_MIN_READ_BUDGET_MS = 1_500; export const DEFAULT_ALERT_TIMEOUT_MS = 10_000; export const ALERT_ACTION_RETRY_MS = 2_000; diff --git a/packages/daemon/src/execution/handlers/__tests__/snapshot-alert.test.ts b/packages/daemon/src/execution/handlers/__tests__/snapshot-alert.test.ts index 034b36333f..7d86cad48f 100644 --- a/packages/daemon/src/execution/handlers/__tests__/snapshot-alert.test.ts +++ b/packages/daemon/src/execution/handlers/__tests__/snapshot-alert.test.ts @@ -129,7 +129,7 @@ test('alert dismiss retries a typed absence whatever the message says', async () let calls = 0; mockRunnerCommand.mockImplementation(async () => { calls += 1; - if (calls < 3) throw alertAbsence('no alert present'); + if (calls < 2) throw alertAbsence('no alert present'); return { dismissed: true }; }); @@ -137,7 +137,7 @@ test('alert dismiss retries a typed absence whatever the message says', async () expect(response).toBeTruthy(); expect(response?.ok).toBe(true); - expect(calls).toBe(3); + expect(calls).toBe(2); }); for (const action of ['accept', 'dismiss']) { diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index e951d83b43..78eb12ad03 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -5,8 +5,10 @@ vi.mock('../core/runner-client.ts', () => ({ runAppleRunnerCommand: vi.fn() })); vi.mock('../os/macos/helper.ts', () => ({ runMacOsAlertAction: vi.fn() })); import { + ALERT_MIN_READ_BUDGET_MS, ALERT_NOT_FOUND_REASON, ALERT_NOT_FOUND_RUNNER_CODE, + ALERT_POLL_INTERVAL_MS, ALERT_UNRESOLVED_RUNNER_CODE, } from '@agent-device/contracts/alert-contract'; import { AppError } from '@agent-device/kernel/errors'; @@ -150,6 +152,33 @@ test('an exhausted accept carries the scoped-snapshot fallback the agent needs n assert.match(String(error.details?.hint), /scoped snapshot/i); }); +test('an action does not start a retry the remaining budget cannot hold', async () => { + vi.useFakeTimers(); + mockRunner.mockRejectedValue(runnerAbsence()); + + const outcome = actOnAppleAlert( + IOS_SIMULATOR, + runnerOptions, + 'accept', + undefined, + process.env, + ).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(5_000); + + await outcome; + const budgets = mockRunner.mock.calls.map((call) => (call[1] as { timeoutMs: number }).timeoutMs); + assert.ok(budgets.length >= 2, 'the act loop never retried'); + // A live late prompt took up to 1.4 s to find and press; a read with less would end in + // MAIN_THREAD_TIMEOUT instead of acting. + for (const budget of budgets.slice(1)) { + assert.ok(budget > 1_400, `retry started with only ${budget} ms`); + assert.ok(budget > ALERT_MIN_READ_BUDGET_MS); + } +}); + // The whole point of typing absence: a backend that failed for any other reason must not be // retried until the window expires and then reported as a timeout. These three pin that the // evidence — not the message text — is what decides. @@ -203,6 +232,22 @@ test('an action does not retry a failure that only reads like an absence', async assert.equal(error.details?.hint, undefined); }); +test('a macOS wait keeps reading until a poll interval remains: its helper has no runner watchdog', async () => { + vi.useFakeTimers(); + const firstReadMs = 5_000 - ALERT_POLL_INTERVAL_MS - ALERT_POLL_INTERVAL_MS - 1; + mockHelper.mockImplementation(async () => { + if (mockHelper.mock.calls.length === 1) { + await new Promise((resolve) => setTimeout(resolve, firstReadMs)); + throw helperAbsence(); + } + return { title: 'Ready' }; + }); + const outcome = awaitAppleAlert(MACOS_DEVICE, runnerOptions, { timeoutMs: 5_000 }, process.env); + await vi.advanceTimersByTimeAsync(firstReadMs + ALERT_POLL_INTERVAL_MS); + assert.deepEqual(await outcome, { title: 'Ready' }); + assert.equal(mockHelper.mock.calls.length, 2); +}); + test('the macOS helper states absence as a typed reason, and the family retries it', async () => { vi.useFakeTimers(); let calls = 0; @@ -315,13 +360,13 @@ test('a wait that ends on an unreadable prompt reports it, not an absence timeou const outcome = awaitAppleAlert( IOS_SIMULATOR, runnerOptions, - { timeoutMs: 900 }, + { timeoutMs: 3_000 }, process.env, ).then( () => undefined, (error: unknown) => error, ); - await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(3_000); const error = (await outcome) as AppError; assert.ok(calls > 1, 'an unreadable prompt is polled until the deadline'); @@ -463,21 +508,22 @@ test('a dispatched wait read propagates its main-thread watchdog after an unreso assert.equal(watchdog.details?.dispatched, 'no'); }); -test('a wait admits another read when more than one poll interval remains', async () => { +test('a wait admits another read when more than the read floor remains', async () => { vi.useFakeTimers(); + const firstReadMs = 5_000 - ALERT_POLL_INTERVAL_MS - ALERT_MIN_READ_BUDGET_MS - 1; mockRunner.mockImplementation(async () => { if (mockRunner.mock.calls.length === 1) { - await new Promise((resolve) => setTimeout(resolve, 4_399)); + await new Promise((resolve) => setTimeout(resolve, firstReadMs)); throw runnerUnresolved(); } return { title: 'Ready' }; }); const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }, process.env); - await vi.advanceTimersByTimeAsync(4_699); + await vi.advanceTimersByTimeAsync(firstReadMs + ALERT_POLL_INTERVAL_MS); assert.deepEqual(await outcome, { title: 'Ready' }); assert.equal( (mockRunner.mock.calls[1]?.[1] as { timeoutMs: number } | undefined)?.timeoutMs, - 301, + ALERT_MIN_READ_BUDGET_MS + 1, ); }); diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index df425bf618..c69d546f26 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -1,5 +1,6 @@ import { ALERT_ACTION_RETRY_MS, + ALERT_MIN_READ_BUDGET_MS, ALERT_NOT_FOUND_REASON, ALERT_NOT_FOUND_RUNNER_CODE, ALERT_POLL_INTERVAL_MS, @@ -85,7 +86,7 @@ export async function readAppleAlert( /** * Poll `get` until one answers or the caller's window runs out. The first attempt gets the whole * window and later ones only what is left, so a runner that blocks cannot outlive the budget. - * After a completed observation, another read needs more than one polling interval of budget. + * After a completed observation, another read needs more than the backend's read floor of budget. * A dispatched read still owns its result, including a watchdog timeout. */ export async function awaitAppleAlert( @@ -95,12 +96,13 @@ export async function awaitAppleAlert( env: Readonly, ): Promise> { const runAlert = runAppleAlert(device, runnerOptions, options, env); + const readFloorMs = minReadBudgetMs(device); const timeout = options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS; const start = Date.now(); let lastRetriable: AppError | undefined; while (Date.now() - start < timeout) { const remainingMs = remainingBudgetMs(start, timeout); - if (lastRetriable && remainingMs <= ALERT_POLL_INTERVAL_MS) { + if (lastRetriable && remainingMs <= readFloorMs) { await sleep(remainingMs); break; } @@ -137,6 +139,7 @@ export async function actOnAppleAlert( env: Readonly, ): Promise> { const runAlert = runAppleAlert(device, runnerOptions, options, env); + const readFloorMs = minReadBudgetMs(device); const runnerTimeoutMs = isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; const start = Date.now(); let lastError: unknown; @@ -153,10 +156,19 @@ export async function actOnAppleAlert( if (!isAlertRetriableError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); + if (remainingBudgetMs(start, ALERT_ACTION_RETRY_MS) <= readFloorMs) break; } throw withAlertFallbackHint(lastError); } +/** + * The XCTest runner cuts a read off with a main-thread timeout when its budget is short, so its + * reads need `ALERT_MIN_READ_BUDGET_MS`. The macOS helper takes no budget and has no such watchdog. + */ +function minReadBudgetMs(device: DeviceInfo): number { + return isMacOs(device) ? ALERT_POLL_INTERVAL_MS : ALERT_MIN_READ_BUDGET_MS; +} + function remainingBudgetMs(start: number, timeoutMs: number): number { return Math.max(1, timeoutMs - (Date.now() - start)); }