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:
'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 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)',

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 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: This shared Boot help text is inaccurate for Android users: boot also launches Android emulators, but the description says the budget covers only a Simulator boot. Describe this as a device boot or as a Simulator/emulator boot.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/command-registry/src/flag-definitions-workflow.ts, line 88:

<comment>This shared Boot help text is inaccurate for Android users: `boot` also launches Android emulators, but the description says the budget covers only a Simulator boot. Describe this as a device boot or as a Simulator/emulator boot.</comment>

<file context>
@@ -85,7 +85,7 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
     usageLabel: '--timeout <ms>',
     usageDescription:
-      '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 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)',
     projectConfig: true,
     recorded: false,
</file context>
Suggested change
'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)',
Fix with cubic

projectConfig: true,
recorded: false,
},
Expand Down
4 changes: 3 additions & 1 deletion packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,9 @@ export const RAW_COMMAND_DESCRIPTORS = [
sessionKind: 'state',
},
platformExecution: { kind: 'device-runtime', uses: deviceBootRuntimeUses },
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
// --timeout is a startup budget: it reaches the Simulator 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' } },

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 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 budget: { source: 'flag' } policy is not platform-gated, but only the Apple runtime consumes timeoutMs: Android's bootTarget/bootTargetHeadless pass it into ensureAndroidReady, which never reads it. So boot --timeout <n> on an Android emulator does not bound the boot wait; it only turns the daemon envelope into n + 30s margin — a user who previously got a fixed 90s envelope can now be cut off at 35s with no boot-side budget, and the flag help text ('Bounds the Simulator boot wait') does not say so. Consider either bounding the Android emulator boot wait with the same deadline or documenting the Apple-only scope of the budget.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/command-registry/src/registry.ts, line 719:

<comment>The new `budget: { source: 'flag' }` policy is not platform-gated, but only the Apple runtime consumes `timeoutMs`: Android's `bootTarget`/`bootTargetHeadless` pass it into `ensureAndroidReady`, which never reads it. So `boot --timeout <n>` on an Android emulator does not bound the boot wait; it only turns the daemon envelope into `n + 30s` margin — a user who previously got a fixed 90s envelope can now be cut off at 35s with no boot-side budget, and the flag help text ('Bounds the Simulator boot wait') does not say so. Consider either bounding the Android emulator boot wait with the same deadline or documenting the Apple-only scope of the budget.</comment>

<file context>
@@ -714,7 +714,9 @@ export const RAW_COMMAND_DESCRIPTORS = [
-    timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
+    // --timeout is a startup budget: it reaches the Simulator 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,
   },
</file context>
Fix with cubic

batchable: true,
},
{
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/client-device-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export type StartupPerfSample = {

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

export type DeviceShutdownOptions = DeviceCommandBaseOptions;
5 changes: 5 additions & 0 deletions packages/contracts/src/device-readiness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import type { DeviceInventoryRequest } from './device-inventory.ts';
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.
*/
deadlineAtMs?: number;
}>;

export type DeviceReadinessRuntimeOperations = Readonly<{
Expand Down
48 changes: 48 additions & 0 deletions packages/platform-apple/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,54 @@ test('readiness and boot keep the Apple automation helper warm inside the platfo
expect(keepHot).toHaveBeenNthCalledWith(3, device);
});

test('bootTarget forwards a --timeout budget as the Simulator boot deadline (#3004)', async () => {
const host = platformRuntimeHostFixture();
let state = 'Shutdown';
const calls: Array<{ args: readonly string[]; timeoutMs?: number }> = [];
const runtime = createApplePlatformRuntime({
...host,
appleTools: {
...host.appleTools,
run: vi.fn(async (request) => {
calls.push({ args: request.args, timeoutMs: request.timeoutMs });
if (request.args.includes('list')) {
return {
stdout: JSON.stringify({ devices: { ios: [{ udid: 'apple-fact', state }] } }),
stderr: '',
exitCode: 0,
};
}
if (request.args.includes('boot')) state = 'Booted';
return { stdout: '', stderr: '', exitCode: 0 };
}),
},
});
const device = appleDevice({ booted: false });
const binding = await runtime.bind({
device,
intent: { kind: 'ordinary' },
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
});

const deadlineAtMs = Date.now() + 45_000;
await binding.operations.bootTarget?.({ deadlineAtMs });

// The startup budget reaches the boot wait, same as open/prepare (#2325): every simctl call the
// wait issues runs under the caller's --timeout budget until the absolute deadline, not a fixed
// default. Both `boot` and `bootstatus` derive their timeout from the same deadline, so both
// must sit within a tight window of the 45s budget - a fixed default like 10s or 30s would fail.
const bootCall = calls.find((call) => call.args.includes('boot'));
const bootstatusCall = calls.find((call) => call.args.includes('bootstatus'));
expect(bootCall?.timeoutMs).toBeGreaterThan(44_900);
expect(bootCall?.timeoutMs).toBeLessThanOrEqual(45_000);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(bootstatusCall?.timeoutMs).toBeGreaterThan(44_900);
expect(bootstatusCall?.timeoutMs).toBeLessThanOrEqual(45_000);
});

test('macOS readiness is a no-op while boot remains unavailable', async () => {
const host = platformRuntimeHostFixture();
const ensureConnected = vi.fn(host.deviceReadiness.applePhysical.ensureConnected);
Expand Down
7 changes: 5 additions & 2 deletions packages/platform-apple/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
appleScreenRecordingFacts,
createAppleScreenRecordingOperations,
} from './recording/runtime.ts';
import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime';
import { ensureAppleReady } from './readiness/runtime.ts';
import { bindAppleApplicationLifecycle } from './lifecycle.ts';
import {
Expand Down Expand Up @@ -475,8 +476,10 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
await ensureAppleReady(host, request.device, request.scope.signal),
})),
...whenAdmitted(facts.operations.bootTarget, () => ({
bootTarget: async () =>
await ensureAppleReady(host, request.device, request.scope.signal),
bootTarget: async (input: EnsureReadyInput) =>
await ensureAppleReady(host, request.device, request.scope.signal, {
deadlineAtMs: input.deadlineAtMs,
}),
})),
...whenAdmitted(facts.operations.listApps, () => ({
listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => {
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/command-descriptor-timeout-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ test('budget sources deviating from the default are bounded, reviewed sets', ()
}
// --timeout bounds the request envelope for these commands only.
assert.deepEqual(flagBoundBudget.sort(), ['replay', 'snapshot']);
// --timeout is a daemon-side startup budget on these commands (#2324); the
// --timeout is a daemon-side startup budget on these commands (#2324, #3004); the
// envelope keeps a margin over it so the daemon's own timeout wins the race.
assert.deepEqual(flagMarginBudget.sort(), ['open', 'prepare']);
assert.deepEqual(flagMarginBudget.sort(), ['boot', 'open', 'prepare']);
// --timeout bounds the --settle wait on these commands (#1101); like wait's
// positional budget it only ever widens the envelope, never shrinks it.
assert.deepEqual(flagWidenBudget.sort(), settleObservationCommandNames());
Expand Down
8 changes: 7 additions & 1 deletion src/commands/management/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DEVICE_KINDS, DEVICE_TARGETS, PUBLIC_PLATFORMS } from '@agent-device/ke
import {
booleanField,
booleanSchema,
integerField,
enumSchema,
looseObjectSchema,
numberSchema,
Expand Down Expand Up @@ -77,6 +78,10 @@ const bootCommandMetadata = defineFieldCommandMetadata(
'Boot or prepare the selected device or simulator so later commands can target it. The device is chosen through the device-selection inputs, not by naming it here.',
{
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.',
{ min: 1 },
),
},
);

Expand All @@ -87,7 +92,7 @@ const shutdownCommandMetadata = defineFieldCommandMetadata(
);

const bootCliSchema = {
allowedFlags: ['headless'],
allowedFlags: ['headless', 'timeoutMs'],
} as const satisfies CommandSchemaOverride;

const devicesCliSchema = {} as const satisfies CommandSchemaOverride;
Expand All @@ -101,6 +106,7 @@ const commonCliReader: CliReader = (_positionals, flags) => commonInputFromFlags
const bootCliReader: CliReader = (_positionals, flags) => ({
...commonInputFromFlags(flags),
headless: flags.headless,
timeoutMs: flags.timeoutMs,
});

const devicesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.devices);
Expand Down
64 changes: 64 additions & 0 deletions src/daemon/handlers/__tests__/session-boot-shutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,70 @@ test('boot prefers explicit device selector over active session device', async (
}
});

test('boot --timeout forwards a startup deadline to bootTarget (#3004)', async () => {
const sessionStore = makeSessionStore();
const selectedDevice: SessionState['device'] = {
platform: 'apple',
id: 'sim-timeout',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: false,
};
mockResolveTargetDevice.mockResolvedValue(selectedDevice);

const beforeMs = Date.now();
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'ios', device: 'iPhone 17 Pro', timeoutMs: 300_000 },
},
sessionName: 'default',
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});
const afterMs = Date.now();

expect(response?.ok, JSON.stringify(response)).toBe(true);
expect(mockEnsureReadyRuntime).toHaveBeenCalledOnce();
const deadlineAtMs = mockEnsureReadyRuntime.mock.calls[0]?.[0]?.deadlineAtMs;
expect(deadlineAtMs).toBeGreaterThanOrEqual(beforeMs + 300_000);
expect(deadlineAtMs).toBeLessThanOrEqual(afterMs + 300_000);
});

test('boot without --timeout leaves the startup deadline unset', async () => {
const sessionStore = makeSessionStore();
const selectedDevice: SessionState['device'] = {
platform: 'apple',
id: 'sim-no-timeout',
name: 'iPhone 17 Pro',
kind: 'simulator',
booted: false,
};
mockResolveTargetDevice.mockResolvedValue(selectedDevice);

const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'ios', device: 'iPhone 17 Pro' },
},
sessionName: 'default',
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response?.ok, JSON.stringify(response)).toBe(true);
expect(mockEnsureReadyRuntime).toHaveBeenCalledOnce();
expect(mockEnsureReadyRuntime.mock.calls[0]?.[0]?.deadlineAtMs).toBeUndefined();
});

test('boot --headless admits a stopped Android emulator through facts and binds once', async () => {
const sessionStore = makeSessionStore();
const placeholder: SessionState['device'] = {
Expand Down
7 changes: 6 additions & 1 deletion src/daemon/handlers/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@agent-device/kernel/device';
import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts';
import { SessionStore } from '../session-store.ts';
import { startupDeadlineAtMs } from '../startup-deadline.ts';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { AppleApplicationState } from '@agent-device/kernel/snapshot';
import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation';
Expand Down Expand Up @@ -302,7 +303,11 @@ export async function handleSessionStateCommands(params: {
});
if (admitted.type === 'response') return admitted.response;

const input = { serial: flags.serial, androidSerialAllowlist };
const input = {
serial: flags.serial,
androidSerialAllowlist,
deadlineAtMs: startupDeadlineAtMs(flags.timeoutMs),
};
if (plan.kind === 'boot-target-headless') {
device = await (await admitted.bind(device, plan.use)).operations.bootTargetHeadless(input);
} else {
Expand Down
6 changes: 2 additions & 4 deletions src/daemon/session-lifecycle/internal/session-open-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { openApplicationRuntimeUse } from '@agent-device/contracts/application-l
import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts';
import { startupDeadlineAtMs } from '../../startup-deadline.ts';
import type { SessionRuntimeHints, SessionState } from '../../session-state.ts';
import { SessionStore } from '../../session-store.ts';
import {
Expand Down Expand Up @@ -229,8 +230,5 @@ async function resolvePreparedOpenIdentity(params: {

/** `open --timeout` is a startup budget; it becomes the absolute deadline the boot wait honors. */
function openStartupDeadlineAtMs(req: DaemonRequest): number | undefined {
const timeoutMs = req.flags?.timeoutMs;
return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
? Date.now() + timeoutMs
: undefined;
return startupDeadlineAtMs(req.flags?.timeoutMs);
}
10 changes: 10 additions & 0 deletions src/daemon/startup-deadline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Converts a `--timeout <ms>` startup budget into the absolute deadline a readiness wait honors.
* A missing, non-finite, or non-positive budget yields `undefined`, so callers fall back to their
* own default wait instead of silently disabling it.
*/
export function startupDeadlineAtMs(timeoutMs: unknown): number | undefined {
return typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
? Date.now() + timeoutMs
: undefined;
}
3 changes: 2 additions & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +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.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
- 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 boot wait is capped at 120 seconds. 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. 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.
- `open <app> <url>` opens a deep link on iOS.
Expand Down
Loading