Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/command-registry/src/flag-definitions-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
min: 1,
usageLabel: '--timeout <ms>',
usageDescription:
'Boot/Open/Prepare: startup budget covering the Simulator boot (and runner preparation for prepare). Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)',
'Boot/Open/Prepare: startup budget covering the device boot (and runner preparation for prepare). Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)',
projectConfig: true,
recorded: false,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
sessionKind: 'state',
},
platformExecution: { kind: 'device-runtime', uses: deviceBootRuntimeUses },
// --timeout is a startup budget: it reaches the Simulator boot wait, same as open/prepare
// --timeout is a startup budget: it reaches the device boot wait, same as open/prepare
// (#2325). A first boot can outlast the fixed 90s envelope (#3004).
timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag', envelope: 'margin' } },
batchable: true,
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/client-device-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export type StartupPerfSample = {

export type DeviceBootOptions = DeviceCommandBaseOptions & {
headless?: boolean;
/** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */
/** Startup budget in milliseconds: bounds the boot wait on a cold Simulator or emulator. */
timeoutMs?: number;
};

Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/device-readiness-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { DeviceInventoryRequest } from './device-inventory.ts';

/** Typed `details.reason` every platform reports when the boot deadline expires. */
export const BOOT_TIMEOUT_REASON = 'boot_timeout';

export type EnsureReadyInput = Readonly<{
serial?: string;
androidSerialAllowlist?: readonly string[];
/**
* Absolute deadline (epoch ms), from `boot --timeout`, already validated finite and positive.
* Bounds a cold Simulator boot wait; the Apple runtime is the only current consumer.
* Bounds a cold boot wait; the Apple and Android runtimes honor it. HarmonyOS, Vega, and Linux
* have no boot wait to bound.
*/
deadlineAtMs?: number;
}>;
Expand Down
8 changes: 6 additions & 2 deletions packages/platform-android/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ export function bindAndroidApplicationLifecycle(
resolveOpenTarget: async (input) =>
await host.androidApplications.resolveOpenTarget(device, input),
prepareApplicationOpen: async (input) => {
await ensureAndroidReady(host, device, { headless: false }, signal);
void input;
await ensureAndroidReady(
host,
device,
{ headless: false, deadlineAtMs: input.execution.startupDeadlineAtMs },
signal,
);
},
openApplication: async (input) => await openAndroidApplication(host, binding, input),
applyRuntimeHints: async (input) =>
Expand Down
89 changes: 89 additions & 0 deletions packages/platform-android/src/readiness/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,95 @@ test('cancellation interrupts Android boot polling and terminates the emulator l
expect(terminate).toHaveBeenCalledWith(4242);
});

function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInfo[] }) {
let now = 1_000;
const run = vi.fn(async () => ({ stdout: overrides.bootCompleted, stderr: '', exitCode: 0 }));
const terminate = vi.fn(async () => {});
const host = {
commands: { which: async () => 'tool', run },
toolchains: { prepare: async () => {} },
clock: {
now: () => now,
sleep: async (ms: number) => {
now += ms;
},
},
deviceReadiness: {
androidEmulator: {
discover: async () => overrides.discover(),
launch: () => 4242,
terminate,
},
},
} as unknown as PlatformRuntimeHost;
return { host, run, terminate, elapsed: () => now - 1_000 };
}

test('a startup deadline bounds the emulator boot wait and expiry reports boot_timeout without killing the emulator', async () => {
const { host, run, terminate, elapsed } = timedHost({
bootCompleted: '0',
discover: () => [runningEmulator()],
});

await expect(
ensureAndroidReady(
host,
runningEmulator(),
{ headless: false, deadlineAtMs: 3_000 },
new AbortController().signal,
),
).rejects.toMatchObject({ details: { reason: 'boot_timeout', serial: 'emulator-5554' } });

expect(elapsed()).toBeLessThan(5_000);
expect(run).toHaveBeenCalled();
expect(terminate).not.toHaveBeenCalled();
});

test('a startup deadline also bounds waiting for a launched emulator to appear', async () => {
const { host, elapsed, terminate } = timedHost({

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Tests 2 and 3 never assert that the emulator was launched, so a regression that removes the launch call in ensureEmulatorReady leaves both tests green (the discovery wait times out on the stopped AVD) and makes expect(terminate).not.toHaveBeenCalled() vacuous. Return the launch mock from timedHost and assert expect(launch).toHaveBeenCalledWith('Pixel_9', true) (and false for a headful case)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/readiness/runtime.test.ts, line 96:

<comment>Tests 2 and 3 never assert that the emulator was launched, so a regression that removes the `launch` call in `ensureEmulatorReady` leaves both tests green (the discovery wait times out on the stopped AVD) and makes `expect(terminate).not.toHaveBeenCalled()` vacuous. Return the `launch` mock from `timedHost` and assert `expect(launch).toHaveBeenCalledWith('Pixel_9', true)` (and `false` for a headful case)</comment>

<file context>
@@ -48,6 +48,95 @@ test('cancellation interrupts Android boot polling and terminates the emulator l
+});
+
+test('a startup deadline also bounds waiting for a launched emulator to appear', async () => {
+  const { host, elapsed, terminate } = timedHost({
+    bootCompleted: '1',
+    discover: () => [stoppedAvd()],
</file context>
Fix with cubic

bootCompleted: '1',
discover: () => [stoppedAvd()],
});

await expect(
ensureAndroidReady(
host,
stoppedAvd(),
{ headless: true, deadlineAtMs: 3_000 },
new AbortController().signal,
),
).rejects.toMatchObject({ details: { reason: 'boot_timeout' } });
expect(elapsed()).toBeLessThan(5_000);
expect(terminate).not.toHaveBeenCalled();
});

test('expiry of a launched emulator that never reports boot_completed leaves it running', async () => {
let discoveries = 0;
const { host, terminate } = timedHost({
bootCompleted: '0',
discover: () => (++discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]),
});

await expect(
ensureAndroidReady(
host,
stoppedAvd(),
{ headless: true, deadlineAtMs: 3_000 },
new AbortController().signal,
),
).rejects.toMatchObject({ details: { reason: 'boot_timeout', serial: 'emulator-5554' } });
expect(terminate).not.toHaveBeenCalled();
});

test('without a deadline the default 120s boot wait applies', async () => {
const { host, elapsed } = timedHost({ bootCompleted: '0', discover: () => [runningEmulator()] });

await expect(
ensureAndroidReady(host, runningEmulator(), { headless: false }, new AbortController().signal),
).rejects.toMatchObject({ details: { reason: 'boot_timeout' } });
expect(elapsed()).toBeGreaterThanOrEqual(120_000);
});

function stoppedAvd(): DeviceInfo {
return {
platform: 'android',
Expand Down
43 changes: 27 additions & 16 deletions packages/platform-android/src/readiness/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime';
import {
BOOT_TIMEOUT_REASON,
type EnsureReadyInput,
} from '@agent-device/contracts/device-readiness-runtime';
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';

/** Readiness reads exactly these host ports; the lifecycle binding composes the same subset. */
Expand All @@ -25,7 +28,9 @@ export async function ensureAndroidReady(
if (device.kind === 'emulator' && (device.booted !== true || !isRunningEmulator(device))) {
return await ensureEmulatorReady(host, device, input, signal);
}
if (device.booted !== true) await waitForBoot(host, device.id, BOOT_TIMEOUT_MS, signal);
if (device.booted !== true) {
await waitForBoot(host, device.id, bootDeadlineAtMs(host, input), signal);
}
return { ...device, booted: true };
}

Expand All @@ -37,6 +42,7 @@ async function ensureEmulatorReady(
): Promise<DeviceInfo> {
await prepareAndroidEmulatorToolchain(host);
const request = inventoryRequest(input);
const deadlineAtMs = bootDeadlineAtMs(host, input);
const available = await host.deviceReadiness.androidEmulator.discover(request, signal);
const selected = requireAvailableAvd(available, device.name, input.serial);

Expand All @@ -46,8 +52,9 @@ async function ensureEmulatorReady(
: host.deviceReadiness.androidEmulator.launch(selected.name, input.headless);
try {
const discovered =
existing ?? (await waitForDiscovery(host, selected.name, request, input.serial, signal));
await waitForBoot(host, discovered.id, BOOT_TIMEOUT_MS, signal);
existing ??
(await waitForDiscovery(host, selected.name, request, input.serial, deadlineAtMs, signal));
await waitForBoot(host, discovered.id, deadlineAtMs, signal);
const refreshed = (await host.deviceReadiness.androidEmulator.discover(request, signal)).find(
(candidate) => candidate.id === discovered.id,
);
Expand Down Expand Up @@ -90,29 +97,24 @@ async function waitForDiscovery(
avdName: string,
request: ReturnType<typeof inventoryRequest>,
serial: string | undefined,
deadline: number,

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The deadline is checked only before each awaited probe, so a probe that finishes after expiry can still return success. Recheck the clock after each probe and before returning the refreshed emulator result so an expired --timeout always yields boot_timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/readiness/runtime.ts, line 100:

<comment>The deadline is checked only before each awaited probe, so a probe that finishes after expiry can still return success. Recheck the clock after each probe and before returning the refreshed emulator result so an expired `--timeout` always yields `boot_timeout`.</comment>

<file context>
@@ -90,29 +97,24 @@ async function waitForDiscovery(
   avdName: string,
   request: ReturnType<typeof inventoryRequest>,
   serial: string | undefined,
+  deadline: number,
   signal: AbortSignal,
 ): Promise<DeviceInfo> {
</file context>
Fix with cubic

signal: AbortSignal,
): Promise<DeviceInfo> {
const deadline = host.clock.now() + BOOT_TIMEOUT_MS;
while (host.clock.now() < deadline) {
const devices = await host.deviceReadiness.androidEmulator.discover(request, signal);
const device = findByAvdName(devices, avdName, serial);
if (device && isRunningEmulator(device)) return device;
await host.clock.sleep(POLL_MS, signal);
}
throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', {
avdName,
serial,
timeoutMs: BOOT_TIMEOUT_MS,
});
throw bootTimeoutError('Android emulator did not appear in time', { avdName, serial });
}

async function waitForBoot(
host: AndroidReadinessHost,
serial: string,
timeoutMs: number,
deadline: number,
signal: AbortSignal,
): Promise<void> {
const deadline = host.clock.now() + timeoutMs;
while (host.clock.now() < deadline) {
const result = await host.commands.run(
{
Expand All @@ -126,13 +128,22 @@ async function waitForBoot(
if (result.stdout.trim() === '1') return;
await host.clock.sleep(POLL_MS, signal);
}
throw new AppError('COMMAND_FAILED', 'Android device failed to finish booting', {
serial,
timeoutMs,
reason: 'ANDROID_BOOT_TIMEOUT',
throw bootTimeoutError('Android device failed to finish booting', { serial });
}

function bootTimeoutError(message: string, details: Record<string, unknown>): AppError {
return new AppError('COMMAND_FAILED', message, {
...details,
reason: BOOT_TIMEOUT_REASON,
hint: 'The emulator keeps booting in the background. Retry once it is up, or pass a larger --timeout.',

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: bootTimeoutError also serves physical Android devices, so this hint incorrectly tells them that an emulator is booting. Use platform-neutral wording such as The Android device keeps booting in the background.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/readiness/runtime.ts, line 138:

<comment>`bootTimeoutError` also serves physical Android devices, so this hint incorrectly tells them that an emulator is booting. Use platform-neutral wording such as `The Android device keeps booting in the background.`</comment>

<file context>
@@ -126,13 +128,22 @@ async function waitForBoot(
+  return new AppError('COMMAND_FAILED', message, {
+    ...details,
+    reason: BOOT_TIMEOUT_REASON,
+    hint: 'The emulator keeps booting in the background. Retry once it is up, or pass a larger --timeout.',
   });
 }
</file context>
Suggested change
hint: 'The emulator keeps booting in the background. Retry once it is up, or pass a larger --timeout.',
hint: 'The Android device keeps booting in the background. Retry once it is up, or pass a larger --timeout.',
Fix with cubic

});
}

/** The caller's `--timeout` deadline when stated, else the default boot wait from now. */
function bootDeadlineAtMs(host: AndroidReadinessHost, input: EnsureReadyInput): number {
return input.deadlineAtMs ?? host.clock.now() + BOOT_TIMEOUT_MS;
}

function inventoryRequest(input: EnsureReadyInput) {
return {
platform: 'android' as const,
Expand Down
3 changes: 2 additions & 1 deletion packages/platform-apple/src/readiness/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BOOT_TIMEOUT_REASON } from '@agent-device/contracts/device-readiness-runtime';
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
Expand Down Expand Up @@ -170,7 +171,7 @@ function bootDeadlineError(device: DeviceInfo, cause?: unknown): AppError {
'COMMAND_FAILED',
'Simulator did not finish booting within the startup budget',
{
reason: 'boot_timeout',
reason: BOOT_TIMEOUT_REASON,
deviceId: device.id,
hint: 'The Simulator keeps booting in the background; a first boot can take several minutes. Retry once it is up, or pass a larger --timeout.',
},
Expand Down
2 changes: 1 addition & 1 deletion src/commands/management/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const bootCommandMetadata = defineFieldCommandMetadata(
{
headless: booleanField('Boot without showing simulator UI when supported.'),
timeoutMs: integerField(
'Startup budget in milliseconds. Bounds the Simulator boot wait, so a never-booted Simulator can finish its first-boot migration; omit for the default startup behavior.',
'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator its boot; omit for the default startup behavior.',

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new help text is ungrammatical: or a cold emulator its boot drops the verb and makes the Android behavior unclear. Say that the budget gives a cold emulator time to finish booting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/management/device.ts, line 82:

<comment>The new help text is ungrammatical: `or a cold emulator its boot` drops the verb and makes the Android behavior unclear. Say that the budget gives a cold emulator time to finish booting.</comment>

<file context>
@@ -79,7 +79,7 @@ const bootCommandMetadata = defineFieldCommandMetadata(
     headless: booleanField('Boot without showing simulator UI when supported.'),
     timeoutMs: integerField(
-      'Startup budget in milliseconds. Bounds the Simulator boot wait, so a never-booted Simulator can finish its first-boot migration; omit for the default startup behavior.',
+      'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator its boot; omit for the default startup behavior.',
       { min: 1 },
     ),
</file context>
Suggested change
'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator its boot; omit for the default startup behavior.',
'Startup budget in milliseconds. Bounds the boot wait, so a never-booted Simulator can finish its first-boot migration or a cold emulator can finish booting; omit for the default startup behavior.',
Fix with cubic

{ min: 1 },
),
},
Expand Down
4 changes: 2 additions & 2 deletions website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ agent-device fold open
- `--platform apple` is an alias for the Apple automation backend (`ios`, `tvOS`, `macOS` selection).
- Use `--target mobile|tv|desktop` with `--platform` (required) to select phone/tablet vs TV-class vs desktop-class targets.
- `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available.
- `boot --timeout <ms>` is a startup budget for the Simulator boot, same as `open`'s. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the command's 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
- `boot --timeout <ms>` is a startup budget for the Simulator or Android emulator boot, same as `open`'s. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the command's 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator or emulator keeps booting, so a retry finds it further along.
- Android: `boot --platform android --device <avd-name>` launches that emulator in GUI mode when needed.
- Android: add `--headless` to launch without opening a GUI window.
- Android: `shutdown --platform android --device <avd-name>` stops a running emulator.
- `open [app|url] [url]` already boots/activates the selected target when needed.
- `open <app> --timeout <ms>` is a startup budget for that boot. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.

@cubic-dev-ai cubic-dev-ai Bot Sep 29, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Android open timeout documentation still says only the Simulator keeps booting. Say “the Simulator or emulator keeps booting” so Android users are told that retrying is safe and expected after boot_timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At website/docs/docs/commands.md, line 77:

<comment>The Android `open` timeout documentation still says only the Simulator keeps booting. Say “the Simulator or emulator keeps booting” so Android users are told that retrying is safe and expected after `boot_timeout`.</comment>

<file context>
@@ -69,12 +69,12 @@ agent-device fold open
 - Android: `shutdown --platform android --device <avd-name>` stops a running emulator.
 - `open [app|url] [url]` already boots/activates the selected target when needed.
-- `open <app> --timeout <ms>` is a startup budget for that boot. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
+- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
 - `open <app> --wait <ms>` waits up to that budget for a device another session is holding instead of failing at once. The open reports each poll, then either opens the device or fails with `DEVICE_IN_USE` naming the owning session and saying the budget was spent. A wait that finds the device taken again keeps waiting for the rest of its budget, so several opens can queue on one device and none of them is refused before its budget is spent. Only session contention is waited for: a device claim held by another workspace's daemon is never retriable and returns its recovery command immediately. The wait extends the command's timeout envelope, so a long budget does not need a longer `--timeout`.
 - `open <url>` deep links are supported on Android and iOS.
</file context>
Suggested change
- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator keeps booting, so a retry finds it further along.
- `open <app> --timeout <ms>` is a startup budget for that boot, on iOS Simulators and Android emulators. A never-booted iOS Simulator runs Apple's first-boot migration, which can take several minutes; without the flag the 90-second request envelope ends the boot first, before the wait's own 120-second cap ever applies. When the budget runs out the command fails with `error.details.reason: boot_timeout` and the Simulator or emulator keeps booting, so a retry finds it further along.
Fix with cubic

- `open <app> --wait <ms>` waits up to that budget for a device another session is holding instead of failing at once. The open reports each poll, then either opens the device or fails with `DEVICE_IN_USE` naming the owning session and saying the budget was spent. A wait that finds the device taken again keeps waiting for the rest of its budget, so several opens can queue on one device and none of them is refused before its budget is spent. Only session contention is waited for: a device claim held by another workspace's daemon is never retriable and returns its recovery command immediately. The wait extends the command's timeout envelope, so a long budget does not need a longer `--timeout`.
- `open <url>` deep links are supported on Android and iOS.
- `open <app> <url>` opens a deep link on iOS.
Expand Down
Loading