Skip to content
Open
9 changes: 9 additions & 0 deletions packages/contracts/src/alert-contract.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,15 @@ 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 };
});

const response = await handleSnapshotCommands(sessionName, sessionStore, ['dismiss']);

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(calls).toBe(3);
expect(calls).toBe(2);
});

for (const action of ['accept', 'dismiss']) {
Expand Down
58 changes: 52 additions & 6 deletions packages/platform-apple/src/__tests__/alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
);
});

Expand Down
16 changes: 14 additions & 2 deletions packages/platform-apple/src/alert.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -95,12 +96,13 @@ export async function awaitAppleAlert(
env: Readonly<EnvMap>,
): Promise<Record<string, unknown>> {
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;
}
Expand Down Expand Up @@ -137,6 +139,7 @@ export async function actOnAppleAlert(
env: Readonly<EnvMap>,
): Promise<Record<string, unknown>> {
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;
Expand All @@ -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));
}
Expand Down
Loading