From f104c7d80878451fcb532f4830daaf771e6fb064 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Sat, 31 Oct 2026 12:00:59 -0400 Subject: [PATCH 1/6] fix(platform-apple): do not start an alert action retry the remaining budget cannot hold The alert act loop slept the poll interval and then started another runner read with whatever budget was left, which could be a few milliseconds. The runner answers such a read with a main-thread timeout, which is not a typed absence, so a quiet screen was reported as a failed read. Stop once the remaining budget cannot hold a read, so the window ends with the last typed absence. The wait loop already guards this on main. --- .../src/__tests__/alert.test.ts | 22 +++++++++++++++++++ packages/platform-apple/src/alert.ts | 13 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index e951d83b43..04b1653dbe 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -150,6 +150,28 @@ 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); + for (const budget of budgets.slice(1)) + assert.ok(budget >= 500, `retry started with ${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. diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index df425bf618..4e68f174da 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -153,14 +153,27 @@ export async function actOnAppleAlert( if (!isAlertRetriableError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); + if (!canHoldRead(start, ALERT_ACTION_RETRY_MS)) break; } throw withAlertFallbackHint(lastError); } +/** + * The least a runner read can be handed and still answer. A retry started with less is cut off by + * the runner's own main-thread timeout, which is a read failure, not a typed absence: the window + * ending on such a read would report "could not read" for a screen that simply had no alert. + */ +const MIN_ALERT_READ_BUDGET_MS = 500; + function remainingBudgetMs(start: number, timeoutMs: number): number { return Math.max(1, timeoutMs - (Date.now() - start)); } +/** True when a retry after the last typed absence still has room for a whole read. */ +function canHoldRead(start: number, timeoutMs: number): boolean { + return remainingBudgetMs(start, timeoutMs) >= MIN_ALERT_READ_BUDGET_MS; +} + function withAlertFallbackHint(error: unknown): unknown { if (!(error instanceof AppError) || !isAlertNotFoundError(error)) return error; return new AppError(error.code, error.message, { From eacd5a81762ca13dd869441b83838619657d934c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Castillo=20Gonz=C3=A1lez?= Date: Thu, 5 Nov 2026 18:49:47 -0400 Subject: [PATCH 2/6] refactor(platform-apple): reuse the poll-interval floor for alert action retries Drops MIN_ALERT_READ_BUDGET_MS and canHoldRead: the act loop now skips a retry when no more than ALERT_POLL_INTERVAL_MS remains, like awaitAppleAlert. The test also asserts the loop retried at least once. Co-Authored-By: Claude Sonnet 5.5 --- .../platform-apple/src/__tests__/alert.test.ts | 4 ++-- packages/platform-apple/src/alert.ts | 16 +++------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index 04b1653dbe..1f208a852b 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -168,8 +168,8 @@ test('an action does not start a retry the remaining budget cannot hold', async await outcome; const budgets = mockRunner.mock.calls.map((call) => (call[1] as { timeoutMs: number }).timeoutMs); - for (const budget of budgets.slice(1)) - assert.ok(budget >= 500, `retry started with ${budget} ms`); + assert.ok(budgets.length >= 2, 'the act loop never retried'); + for (const budget of budgets.slice(1)) assert.ok(budget > 300, `retry started with ${budget} ms`); }); // The whole point of typing absence: a backend that failed for any other reason must not be diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index 4e68f174da..62551dfab5 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -153,27 +153,17 @@ export async function actOnAppleAlert( if (!isAlertRetriableError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); - if (!canHoldRead(start, ALERT_ACTION_RETRY_MS)) break; + // Same floor as `awaitAppleAlert`: a retry with no more than one poll interval left would be + // cut off by the runner's main-thread timeout and misreported as an unreadable prompt. + if (remainingBudgetMs(start, ALERT_ACTION_RETRY_MS) <= ALERT_POLL_INTERVAL_MS) break; } throw withAlertFallbackHint(lastError); } -/** - * The least a runner read can be handed and still answer. A retry started with less is cut off by - * the runner's own main-thread timeout, which is a read failure, not a typed absence: the window - * ending on such a read would report "could not read" for a screen that simply had no alert. - */ -const MIN_ALERT_READ_BUDGET_MS = 500; - function remainingBudgetMs(start: number, timeoutMs: number): number { return Math.max(1, timeoutMs - (Date.now() - start)); } -/** True when a retry after the last typed absence still has room for a whole read. */ -function canHoldRead(start: number, timeoutMs: number): boolean { - return remainingBudgetMs(start, timeoutMs) >= MIN_ALERT_READ_BUDGET_MS; -} - function withAlertFallbackHint(error: unknown): unknown { if (!(error instanceof AppError) || !isAlertNotFoundError(error)) return error; return new AppError(error.code, error.message, { From 3aa1507721801884f86446712be16ac3d3792028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 7 Nov 2026 14:38:20 +0100 Subject: [PATCH 3/6] fix(platform-apple): start an alert read only with enough budget to find and press a late prompt Both alert loops skipped a read with no more than one poll interval left. A live run showed a permission prompt appearing about 1 s into the command still ended MAIN_THREAD_TIMEOUT: the read that saw it started with 300-760 ms left, and a read needs up to about 1.4 s to find and press the button. Replace the shared poll-interval floor with one named ALERT_MIN_READ_BUDGET_MS (1.5 s) in the alert contract, used by awaitAppleAlert and actOnAppleAlert. Co-Authored-By: Claude Sonnet 5.5 --- packages/contracts/src/alert-contract.ts | 7 ++++++ .../src/__tests__/alert.test.ts | 22 +++++++++++++------ packages/platform-apple/src/alert.ts | 9 ++++---- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index ecf4f6a764..953b53a716 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -1,4 +1,11 @@ export const ALERT_POLL_INTERVAL_MS = 300; +/** + * A runner read starts only with more than this much budget left. Finding a prompt and acting on + * it takes longer than one poll interval, and a read cut off by the runner's main-thread watchdog + * is reported as `MAIN_THREAD_TIMEOUT` instead of absence. Live simulator reads that saw a late + * prompt needed up to about 1.4 s. + */ +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/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index 1f208a852b..7f1abfa446 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'; @@ -169,7 +171,12 @@ test('an action does not start a retry the remaining budget cannot hold', async 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'); - for (const budget of budgets.slice(1)) assert.ok(budget > 300, `retry started with ${budget} ms`); + // 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 @@ -337,13 +344,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'); @@ -485,21 +492,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 62551dfab5..d10955a34e 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 `ALERT_MIN_READ_BUDGET_MS` of budget. * A dispatched read still owns its result, including a watchdog timeout. */ export async function awaitAppleAlert( @@ -100,7 +101,7 @@ export async function awaitAppleAlert( let lastRetriable: AppError | undefined; while (Date.now() - start < timeout) { const remainingMs = remainingBudgetMs(start, timeout); - if (lastRetriable && remainingMs <= ALERT_POLL_INTERVAL_MS) { + if (lastRetriable && remainingMs <= ALERT_MIN_READ_BUDGET_MS) { await sleep(remainingMs); break; } @@ -153,9 +154,7 @@ export async function actOnAppleAlert( if (!isAlertRetriableError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); - // Same floor as `awaitAppleAlert`: a retry with no more than one poll interval left would be - // cut off by the runner's main-thread timeout and misreported as an unreadable prompt. - if (remainingBudgetMs(start, ALERT_ACTION_RETRY_MS) <= ALERT_POLL_INTERVAL_MS) break; + if (remainingBudgetMs(start, ALERT_ACTION_RETRY_MS) <= ALERT_MIN_READ_BUDGET_MS) break; } throw withAlertFallbackHint(lastError); } From 51cb6c6d818820cdf2cba171ff4ceed6c2a718e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 7 Nov 2026 14:47:53 +0100 Subject: [PATCH 4/6] test(daemon): dismiss retries a typed absence once under the alert read floor Co-Authored-By: Claude Sonnet 5.5 --- .../src/execution/handlers/__tests__/snapshot-alert.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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']) { From 2ab8574bbd8582ee654cb1f8dcc34179391a823c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 7 Nov 2026 15:08:26 +0100 Subject: [PATCH 5/6] fix(platform-apple): keep the poll-interval read floor for the macOS helper The 1.5 s floor exists for the XCTest runner's main-thread watchdog. The macOS helper takes no budget, so it keeps reading until one poll interval remains. Co-Authored-By: Claude Sonnet 5.5 --- packages/contracts/src/alert-contract.ts | 3 ++- .../platform-apple/src/__tests__/alert.test.ts | 16 ++++++++++++++++ packages/platform-apple/src/alert.ts | 16 +++++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index 953b53a716..3a7ba66dac 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -1,6 +1,7 @@ export const ALERT_POLL_INTERVAL_MS = 300; /** - * A runner read starts only with more than this much budget left. Finding a prompt and acting on + * The floor for a retry read through the XCTest runner: the loops start no later read with this + * much budget or less left. Finding a prompt and acting on * it takes longer than one poll interval, and a read cut off by the runner's main-thread watchdog * is reported as `MAIN_THREAD_TIMEOUT` instead of absence. Live simulator reads that saw a late * prompt needed up to about 1.4 s. diff --git a/packages/platform-apple/src/__tests__/alert.test.ts b/packages/platform-apple/src/__tests__/alert.test.ts index 7f1abfa446..78eb12ad03 100644 --- a/packages/platform-apple/src/__tests__/alert.test.ts +++ b/packages/platform-apple/src/__tests__/alert.test.ts @@ -232,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; diff --git a/packages/platform-apple/src/alert.ts b/packages/platform-apple/src/alert.ts index d10955a34e..c69d546f26 100644 --- a/packages/platform-apple/src/alert.ts +++ b/packages/platform-apple/src/alert.ts @@ -86,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 `ALERT_MIN_READ_BUDGET_MS` 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( @@ -96,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_MIN_READ_BUDGET_MS) { + if (lastRetriable && remainingMs <= readFloorMs) { await sleep(remainingMs); break; } @@ -138,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; @@ -154,11 +156,19 @@ export async function actOnAppleAlert( if (!isAlertRetriableError(error)) break; } await sleep(ALERT_POLL_INTERVAL_MS); - if (remainingBudgetMs(start, ALERT_ACTION_RETRY_MS) <= ALERT_MIN_READ_BUDGET_MS) break; + 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)); } From 4eba55dfa90239c57299c84edfacf1c2e20d1007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 7 Nov 2026 15:24:34 +0100 Subject: [PATCH 6/6] docs(contracts): state that the alert read floor gates retry reads only Co-Authored-By: Claude Sonnet 5.5 --- packages/contracts/src/alert-contract.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index 3a7ba66dac..d3d23329a4 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -1,10 +1,11 @@ export const ALERT_POLL_INTERVAL_MS = 300; /** - * The floor for a retry read through the XCTest runner: the loops start no later read with this - * much budget or less left. Finding a prompt and acting on - * it takes longer than one poll interval, and a read cut off by the runner's main-thread watchdog - * is reported as `MAIN_THREAD_TIMEOUT` instead of absence. Live simulator reads that saw a late - * prompt needed up to about 1.4 s. + * 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;