diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index d4790a84e3..156a940c48 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -27,6 +27,8 @@ export function hasRuntimeTransportHintValues(values: RuntimeHintValues): boolea /** Request-scoped runner/diagnostic context, without daemon request types. */ export type ApplicationLifecycleExecution = Readonly<{ + /** Absolute daemon-owned deadline shared by boot and runner preparation. */ + startupDeadlineAtMs?: number; requestId?: string; logPath?: string; traceLogPath?: string; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index bf4bef329b..496a3a2c5c 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -66,6 +66,8 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & launchConsole?: string; launchArgs?: string[]; relaunch?: boolean; + /** Startup budget in milliseconds, including device boot and Apple runner readiness. */ + timeoutMs?: number; /** * Include the initial interactive snapshot in a fresh open response. With * no app argument, iOS can discover the sole running app on the sole booted diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 7c990f78f3..e0f7fec709 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -5,6 +5,13 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindAppleApplicationLifecycle } from './lifecycle.ts'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} const device: DeviceInfo = { platform: 'apple', @@ -259,3 +266,81 @@ function openInput(): OpenApplicationInput { execution: {}, }; } + +test('an explicit startup deadline waits for runner readiness before opening the app', async () => { + const baseHost = platformRuntimeHostFixture(); + const ready = deferred(); + const prewarmStarted = deferred(); + const open = vi.fn(async () => {}); + const prewarmRunnerSession = vi.fn< + PlatformRuntimeHost['appleApplications']['prewarmRunnerSession'] + >(async () => { + prewarmStarted.resolve(); + await ready.promise; + }); + const lifecycle = bindAppleApplicationLifecycle({ + device: { ...device, kind: 'simulator' }, + signal: new AbortController().signal, + host: { + ...baseHost, + localInteractors: { resolve: async () => ({ open }) as unknown as Interactor }, + appleApplications: { ...baseHost.appleApplications, prewarmRunnerSession }, + }, + }); + const pending = lifecycle.openApplication({ + ...openInput(), + relaunch: false, + hasExistingSession: false, + execution: { startupDeadlineAtMs: Date.now() + 600_000 }, + }); + await prewarmStarted.promise; + await new Promise((resolve) => setImmediate(resolve)); + expect(open).not.toHaveBeenCalled(); + ready.resolve(); + await pending; + expect(open).toHaveBeenCalledOnce(); + expect(prewarmRunnerSession.mock.calls[0]?.[3]).toBe(true); +}); + +test('prepare shares its timeout between simulator boot and runner preparation', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + const baseHost = platformRuntimeHostFixture(); + const prepareRunner = vi.fn(baseHost.appleApplications.prepareRunner); + const run = vi.fn(async (request) => { + if (request.args.includes('bootstatus')) now.mockReturnValue(301_000); + return { + stdout: request.args.includes('list') + ? JSON.stringify({ + devices: { + ios: [{ udid: device.id, state: Date.now() === 1_000 ? 'Shutdown' : 'Booted' }], + }, + }) + : '', + stderr: '', + exitCode: 0, + }; + }); + try { + const lifecycle = bindAppleApplicationLifecycle({ + host: { + ...baseHost, + appleTools: { ...baseHost.appleTools, run }, + appleApplications: { ...baseHost.appleApplications, prepareRunner }, + }, + device: { ...device, kind: 'simulator' }, + signal: new AbortController().signal, + }); + await lifecycle.prepareAppleRunner({ timeoutMs: 600_000, execution: {} }); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ args: ['bootstatus', device.id, '-b'], timeoutMs: 600_000 }), + expect.any(AbortSignal), + ); + expect(prepareRunner).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeoutMs: 300_000 }), + expect.any(AbortSignal), + ); + } finally { + now.mockRestore(); + } +}); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index ad03d7df9e..2a11bba462 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -59,6 +59,7 @@ export function bindAppleApplicationLifecycle( await params.host.appleApplications.resolveOpenTarget(params.device, input), prepareApplicationOpen: async (input) => { await ensureAppleReady(params.host, params.device, params.signal, { + deadlineAtMs: input.execution.startupDeadlineAtMs, onColdBootStart: input.prewarmRunnerOnColdBoot ? () => { void params.host.appleApplications @@ -102,7 +103,8 @@ async function openAppleApplication( input, localIosSimulator, ); - if (localIosSimulator && shouldPrewarmRunner && !input.prewarmRunnerBeforeOpen) runner.schedule(); + const requireRunnerReady = requiresRunnerReadiness(input); + if (localIosSimulator && shouldPrewarmRunner && !requireRunnerReady) runner.schedule(); try { await closeAppleApplicationForRelaunch( host, @@ -113,7 +115,7 @@ async function openAppleApplication( timing, ); await applyAppleOpenRuntimeHints(input, timing); - await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, input.prewarmRunnerBeforeOpen); + await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, requireRunnerReady); const runnerTargetPredatesOpen = runner.wasAwaited(); await dispatchAppleOpen(binding, input, localIosSimulator, timing); await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, input.relaunch); @@ -135,6 +137,10 @@ async function openAppleApplication( } } +function requiresRunnerReadiness(input: OpenApplicationInput): boolean { + return input.prewarmRunnerBeforeOpen || input.execution.startupDeadlineAtMs !== undefined; +} + async function closeAppleApplicationForRelaunch( host: AppleLifecycleHost, binding: BoundAppleInteractor, @@ -343,8 +349,15 @@ async function prepareAppleRunner( signal: AbortSignal, input: PrepareAppleRunnerInput, ): Promise { - await ensureAppleReady(host, device, signal); - return await host.appleApplications.prepareRunner(device, input, signal); + const deadlineAtMs = Date.now() + input.timeoutMs; + await ensureAppleReady(host, device, signal, { deadlineAtMs }); + const timeoutMs = deadlineAtMs - Date.now(); + if (timeoutMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Apple runner preparation deadline exceeded', { + reason: 'startup_timeout', + }); + } + return await host.appleApplications.prepareRunner(device, { ...input, timeoutMs }, signal); } type RunnerPrewarm = Readonly<{ diff --git a/packages/platform-apple/src/readiness/runtime.test.ts b/packages/platform-apple/src/readiness/runtime.test.ts index 64aaf7e49b..9fd642c793 100644 --- a/packages/platform-apple/src/readiness/runtime.test.ts +++ b/packages/platform-apple/src/readiness/runtime.test.ts @@ -3,6 +3,13 @@ import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runti import type { DeviceInfo } from '@agent-device/kernel/device'; import { platformRuntimeHostFixture } from '../runtime.fixtures.ts'; import { ensureAppleReady } from './runtime.ts'; +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} test('recent native boot observation avoids a duplicate simulator listing', async () => { const run = vi.fn(async () => ({ @@ -166,3 +173,136 @@ function simulator(overrides: Partial = {}): DeviceInfo { ...overrides, }; } + +test('cold bootstatus receives the remaining shared startup budget rather than a fixed cap', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + const host = platformRuntimeHostFixture(); + let booted = false; + const run = vi.fn(async (request) => { + if (request.args.includes('boot')) now.mockReturnValue(11_000); + if (request.args.includes('bootstatus')) booted = true; + return { + stdout: request.args.includes('list') + ? JSON.stringify({ + devices: { ios: [{ udid: 'sim-1', state: booted ? 'Booted' : 'Shutdown' }] }, + }) + : '', + stderr: '', + exitCode: 0, + }; + }); + try { + await ensureAppleReady( + { ...host, appleTools: { ...host.appleTools, run } }, + simulator(), + new AbortController().signal, + { deadlineAtMs: 601_000 }, + ); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ args: ['bootstatus', 'sim-1', '-b'], timeoutMs: 590_000 }), + expect.any(AbortSignal), + ); + } finally { + now.mockRestore(); + } +}); + +test('canceled readiness does not settle until its exact simulator shutdown completes', async () => { + const host = platformRuntimeHostFixture(); + const controller = new AbortController(); + const shutdown = deferred<{ stdout: string; stderr: string; exitCode: number }>(); + const shutdownStarted = deferred(); + const run = vi.fn(async (request) => { + if (request.args.includes('list')) + return { + stdout: JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state: 'Shutdown' }] } }), + stderr: '', + exitCode: 0, + }; + if (request.args.includes('bootstatus')) { + controller.abort(new Error('cancel')); + throw controller.signal.reason; + } + if (request.args.includes('shutdown')) { + shutdownStarted.resolve(); + return await shutdown.promise; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + let settled = false; + const pending = ensureAppleReady( + { ...host, appleTools: { ...host.appleTools, run } }, + simulator(), + controller.signal, + ).catch((error) => { + settled = true; + throw error; + }); + const rejection = expect(pending).rejects.toThrow('cancel'); + await shutdownStarted.promise; + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(run).toHaveBeenLastCalledWith( + expect.objectContaining({ args: ['shutdown', 'sim-1'], timeoutMs: 15_000 }), + ); + shutdown.resolve({ stdout: '', stderr: '', exitCode: 0 }); + await rejection; +}); + +test('an explicit startup budget waits for initialization even when inventory reports Booted', async () => { + const host = platformRuntimeHostFixture(); + const run = vi.fn(async () => ({ + stdout: '', + stderr: '', + exitCode: 0, + })); + await ensureAppleReady( + { + ...host, + appleTools: { ...host.appleTools, run }, + deviceReadiness: { + ...host.deviceReadiness, + appleAutomation: { + ...host.deviceReadiness.appleAutomation, + wasRecentlyObservedBooted: async () => true, + }, + }, + }, + simulator(), + new AbortController().signal, + { deadlineAtMs: Date.now() + 600_000 }, + ); + expect(run).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ args: ['bootstatus', 'sim-1', '-b'] }), + expect.any(AbortSignal), + ); +}); + +test('cancellation during the boot command reconciles its potentially started simulator', async () => { + const host = platformRuntimeHostFixture(); + const controller = new AbortController(); + const run = vi.fn(async (request) => { + if (request.args.includes('list')) + return { + stdout: JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state: 'Shutdown' }] } }), + stderr: '', + exitCode: 0, + }; + if (request.args.includes('boot')) { + controller.abort(new Error('cancel boot command')); + throw controller.signal.reason; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + await expect( + ensureAppleReady( + { ...host, appleTools: { ...host.appleTools, run } }, + simulator(), + controller.signal, + ), + ).rejects.toThrow('cancel boot command'); + expect(run).toHaveBeenLastCalledWith( + expect.objectContaining({ args: ['shutdown', 'sim-1'], timeoutMs: 15_000 }), + ); +}); diff --git a/packages/platform-apple/src/readiness/runtime.ts b/packages/platform-apple/src/readiness/runtime.ts index fe0925fe0e..2c8409022e 100644 --- a/packages/platform-apple/src/readiness/runtime.ts +++ b/packages/platform-apple/src/readiness/runtime.ts @@ -1,6 +1,7 @@ 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'; +import { emitRequestProgress } from '@agent-device/host-kit/request'; import { getSimulatorState, simctlArgs } from '../simulator-state.ts'; /** Readiness reads exactly these host ports; the lifecycle binding composes the same subset. */ @@ -13,6 +14,7 @@ const BOOT_TIMEOUT_MS = 120_000; const LIST_TIMEOUT_MS = 15_000; export type AppleReadinessOptions = Readonly<{ + deadlineAtMs?: number; /** * Runs when this call is about to cold-boot the simulator. `open` uses it to start warming the * runner cache in parallel with the boot, which is the whole reason the hook exists. @@ -39,8 +41,10 @@ export async function ensureAppleReady( if (state !== 'Booted') { options.onColdBootStart?.(); host.deviceReadiness.appleAutomation.keepHot(device); - await bootSimulator(host, device, signal); + await bootSimulator(host, device, signal, options.deadlineAtMs); await showSimulator(host, signal); + } else if (options.deadlineAtMs !== undefined) { + await waitForSimulatorBoot(host, device, signal, options.deadlineAtMs); } host.deviceReadiness.appleAutomation.keepHot(device); // Publish the fresh observation so the boot checks later in this flow skip their own listing. @@ -63,15 +67,18 @@ async function bootSimulator( host: AppleReadinessHost, device: DeviceInfo, signal: AbortSignal, + deadlineAtMs?: number, ): Promise { let started = false; try { + signal.throwIfAborted(); + started = true; const boot = await host.appleTools.run( { tool: 'simctl', args: simctlArgs(device, ['boot', device.id]), allowFailure: true, - timeoutMs: BOOT_TIMEOUT_MS, + timeoutMs: remainingBootBudget(deadlineAtMs), }, signal, ); @@ -86,32 +93,52 @@ async function bootSimulator( }); } started = !alreadyBooted; - const status = await host.appleTools.run( - { - tool: 'simctl', - args: simctlArgs(device, ['bootstatus', device.id, '-b']), - allowFailure: true, - timeoutMs: BOOT_TIMEOUT_MS, - }, - signal, - ); - if (status.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { - stdout: status.stdout, - stderr: status.stderr, - exitCode: status.exitCode, - }); - } + await waitForSimulatorBoot(host, device, signal, deadlineAtMs); if ((await simulatorState(host, device, signal)) !== 'Booted') { throw new AppError('COMMAND_FAILED', 'Simulator is still booting', { deviceId: device.id }); } } catch (error) { - if (started && signal.aborted) scheduleSimulatorShutdown(host, device); + if (started && wasBootCanceled(signal, deadlineAtMs)) { + await shutdownCanceledSimulator(host, device); + } signal.throwIfAborted(); throw error; } } +function wasBootCanceled(signal: AbortSignal, deadlineAtMs: number | undefined): boolean { + return signal.aborted || (deadlineAtMs !== undefined && Date.now() >= deadlineAtMs); +} + +async function waitForSimulatorBoot( + host: AppleReadinessHost, + device: DeviceInfo, + signal: AbortSignal, + deadlineAtMs?: number, +): Promise { + emitRequestProgress({ + type: 'command', + status: 'progress', + message: 'Waiting for iOS simulator boot initialization.', + }); + const status = await host.appleTools.run( + { + tool: 'simctl', + args: simctlArgs(device, ['bootstatus', device.id, '-b']), + allowFailure: true, + timeoutMs: remainingBootBudget(deadlineAtMs), + }, + signal, + ); + if (status.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { + stdout: status.stdout, + stderr: status.stderr, + exitCode: status.exitCode, + }); + } +} + async function simulatorState( host: AppleReadinessHost, device: DeviceInfo, @@ -127,8 +154,39 @@ async function showSimulator(host: AppleReadinessHost, signal: AbortSignal): Pro ); } -function scheduleSimulatorShutdown(host: AppleReadinessHost, device: DeviceInfo): void { - void host.appleTools - .run({ tool: 'simctl', args: simctlArgs(device, ['shutdown', device.id]), allowFailure: true }) - .catch(() => {}); +function remainingBootBudget(deadlineAtMs: number | undefined): number { + if (deadlineAtMs === undefined) return BOOT_TIMEOUT_MS; + const remainingMs = deadlineAtMs - Date.now(); + if (remainingMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Application startup deadline exceeded', { + reason: 'startup_timeout', + }); + } + return remainingMs; +} + +async function shutdownCanceledSimulator( + host: AppleReadinessHost, + device: DeviceInfo, +): Promise { + emitRequestProgress({ + type: 'command', + status: 'progress', + message: 'Stopping the simulator started by the canceled request.', + }); + try { + const result = await host.appleTools.run({ + tool: 'simctl', + args: simctlArgs(device, ['shutdown', device.id]), + allowFailure: true, + timeoutMs: 15_000, + }); + if (result.exitCode === 0) return; + } catch { + // The caller must retain ownership when shutdown cannot be confirmed. + } + throw new AppError('COMMAND_FAILED', 'Canceled simulator boot cleanup failed', { + reason: 'ios_boot_cleanup_failed', + deviceId: device.id, + }); } diff --git a/packages/platform-apple/src/shutdown/runtime.test.ts b/packages/platform-apple/src/shutdown/runtime.test.ts index c78b5df7eb..80e360d45e 100644 --- a/packages/platform-apple/src/shutdown/runtime.test.ts +++ b/packages/platform-apple/src/shutdown/runtime.test.ts @@ -14,13 +14,16 @@ beforeEach(() => { run.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); }); -test('an already-stopped simulator succeeds without native shutdown', async () => { +test('a stale stopped inventory hint still dispatches native shutdown', async () => { const runtime = createAppleShutdownRuntime({ appleTools }); await expect(runtime.shutdownTarget(appleDevice({ booted: false }), signal())).resolves.toEqual( success(), ); - expect(run).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledExactlyOnceWith( + { tool: 'simctl', args: ['shutdown', 'sim-1'], allowFailure: true, timeoutMs: 15_000 }, + expect.any(AbortSignal), + ); }); test('a shutdown error is successful when final simulator state is Shutdown', async () => { diff --git a/packages/platform-apple/src/shutdown/runtime.ts b/packages/platform-apple/src/shutdown/runtime.ts index c9b49bfc4d..394c1f92e4 100644 --- a/packages/platform-apple/src/shutdown/runtime.ts +++ b/packages/platform-apple/src/shutdown/runtime.ts @@ -30,8 +30,6 @@ async function shutdownAppleTarget( device: DeviceInfo, signal: AbortSignal, ): Promise { - if (device.booted === false) return stoppedTargetSuccess(); - signal.throwIfAborted(); try { const result = await appleTools.run( @@ -90,7 +88,3 @@ function toShutdownResult(result: { stderr: result.stderr, }; } - -function stoppedTargetSuccess(): TargetShutdownResult { - return { success: true, exitCode: 0, stdout: '', stderr: '' }; -} diff --git a/src/commands/cli-grammar/flag-definitions-workflow.ts b/src/commands/cli-grammar/flag-definitions-workflow.ts index c82dad2ca5..ed5792a70c 100644 --- a/src/commands/cli-grammar/flag-definitions-workflow.ts +++ b/src/commands/cli-grammar/flag-definitions-workflow.ts @@ -71,7 +71,7 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ min: 1, usageLabel: '--timeout ', usageDescription: - 'Prepare/Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)', + 'Open/Prepare: startup budget including boot and Apple runner readiness, plus client cleanup grace. Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s)', }, { key: 'retries', diff --git a/src/commands/management/app.test.ts b/src/commands/management/app.test.ts index 343fe9fac1..8cab53a78e 100644 --- a/src/commands/management/app.test.ts +++ b/src/commands/management/app.test.ts @@ -38,6 +38,52 @@ function createOpenClient(params: { stateDir: string; session: string; sessionRe } describe('open command metro session hints', () => { + test('open accepts and projects an explicit startup timeout without settle', async () => { + const parsed = parseArgs(['open', 'Settings', '--timeout', '600000'], { + strictFlags: true, + }); + const stateDir = tempStateDir(); + try { + const { client, calls } = createOpenClient({ stateDir, session: 'cold-start' }); + await openCommandFacet.definition.invoke( + client, + openCommandFacet.cliReader(parsed.positionals, parsed.flags), + ); + expect(calls[0]?.flags?.timeoutMs).toBe(600_000); + expect(calls[0]?.flags?.settle).toBeUndefined(); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + + test.each([0, -1, 1.5, 2_147_453_648])( + 'open rejects invalid structured startup timeout %s', + (timeoutMs) => { + expect(() => openCommandFacet.metadata.readInput({ app: 'Settings', timeoutMs })).toThrow( + /timeoutMs/, + ); + }, + ); + + test('CLI invocation rejects startup timer overflow before dispatch', async () => { + const parsed = parseArgs(['open', 'Settings', '--timeout', '2147453648'], { + strictFlags: true, + }); + const stateDir = tempStateDir(); + try { + const { client, calls } = createOpenClient({ stateDir, session: 'cold-start' }); + await expect( + openCommandFacet.definition.invoke( + client, + openCommandFacet.cliReader(parsed.positionals, parsed.flags), + ), + ).rejects.toThrow(/timeoutMs/); + expect(calls).toHaveLength(0); + } finally { + rmSync(stateDir, { recursive: true, force: true }); + } + }); + test('CLI parser accepts --metro-host/--metro-port/--bundle-url/--launch-url on open', () => { const parsed = parseArgs( [ diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index a0eb3508b6..9667119fa5 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -1,4 +1,5 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { MAX_STARTUP_TIMEOUT_MS } from '../../core/command-descriptor/timeout-policy.ts'; import type { AppCloseOptions, AppOpenOptions } from '@agent-device/contracts/client'; import { DEFAULT_APPS_FILTER } from '@agent-device/contracts/device'; import { SESSION_SURFACES } from '@agent-device/contracts/session'; @@ -49,6 +50,10 @@ const openCommandMetadata = defineFieldCommandMetadata( 'Launch arguments forwarded verbatim to the platform launch command.', ), relaunch: booleanField('Force relaunch.'), + timeoutMs: integerField( + 'Startup budget in milliseconds, including device boot and Apple runner readiness. Omit to keep the default startup behavior.', + { min: 1, max: MAX_STARTUP_TIMEOUT_MS }, + ), foreground: booleanField( 'Include an initial interactive snapshot in a fresh open response. With no app argument, discover the sole running app on the sole booted iOS simulator; ambiguous environments fail closed.', ), @@ -121,6 +126,7 @@ const openCliSchema = { 'noRecord', 'relaunch', 'foreground', + 'timeoutMs', 'surface', ...METRO_RELOAD_FLAGS, 'launchUrl', @@ -147,6 +153,7 @@ const openCliReader: CliReader = (positionals, flags) => ({ launchArgs: flags.launchArgs, relaunch: flags.relaunch, foreground: flags.foreground, + timeoutMs: flags.timeoutMs, saveScript: flags.saveScript, force: flags.force, deviceHub: flags.deviceHub, diff --git a/src/commands/management/prepare.ts b/src/commands/management/prepare.ts index 77f366d24e..3796bdddca 100644 --- a/src/commands/management/prepare.ts +++ b/src/commands/management/prepare.ts @@ -1,4 +1,5 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { MAX_STARTUP_TIMEOUT_MS } from '../../core/command-descriptor/timeout-policy.ts'; import type { CommandSchemaOverride } from '../../cli-schema/types.ts'; import { enumField, integerField, requiredField } from '../command-input.ts'; import { @@ -19,7 +20,10 @@ const prepareCommandMetadata = defineFieldCommandMetadata( 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In JSON output, top-level buildMs/connectMs/healthCheckMs are diagnostic fields and may overlap; use timing.additiveParts for additive wall-clock phase totals. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, stop the prepare daemon before replay/test so it does not keep the prepared runner lease. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', { action: requiredField(enumField(PREPARE_ACTION_VALUES)), - timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), + timeoutMs: integerField( + 'Startup budget in milliseconds for device boot and runner preparation; the client allows additional cleanup time.', + { min: 1, max: MAX_STARTUP_TIMEOUT_MS }, + ), }, ); diff --git a/src/core/command-descriptor/__tests__/timeout-policy.test.ts b/src/core/command-descriptor/__tests__/timeout-policy.test.ts index e458226571..38d7a9e426 100644 --- a/src/core/command-descriptor/__tests__/timeout-policy.test.ts +++ b/src/core/command-descriptor/__tests__/timeout-policy.test.ts @@ -100,10 +100,15 @@ test('daemon-preserving timeout commands are a bounded, reviewed set', () => { test('budget sources deviating from the default are bounded, reviewed sets', () => { const flagBoundBudget: string[] = []; const flagWidenBudget: string[] = []; + const flagBudgetPlusMargin: string[] = []; const positionalBudget: string[] = []; for (const descriptor of commandDescriptors) { const budget = descriptor.timeoutPolicy.budget; if (budget.source === 'flag') { + if ('envelope' in budget && budget.envelope === 'budget-plus-margin') { + flagBudgetPlusMargin.push(descriptor.name); + continue; + } const widen = 'envelope' in budget && budget.envelope === 'widen'; (widen ? flagWidenBudget : flagBoundBudget).push(descriptor.name); } @@ -112,7 +117,8 @@ test('budget sources deviating from the default are bounded, reviewed sets', () } } // --timeout bounds the request envelope for these commands only. - assert.deepEqual(flagBoundBudget.sort(), ['prepare', 'replay', 'snapshot']); + assert.deepEqual(flagBoundBudget.sort(), ['replay', 'snapshot']); + assert.deepEqual(flagBudgetPlusMargin.sort(), ['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()); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index aa1354cd4e..3c36a75c89 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -946,7 +946,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ allowSessionlessDefaultDevice: allowAnyDeviceSessionless, saveScriptFlagOwner: true, }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + timeoutPolicy: { + ...DEFAULT_TIMEOUT_POLICY, + budget: { source: 'flag', envelope: 'budget-plus-margin' }, + }, batchable: true, platformExecution: { kind: 'device-runtime', uses: openApplicationRuntimePlanUses }, }, @@ -958,9 +961,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'preserve' }, - // Runner warm-up builds are the longest fixed envelope; --timeout overrides. timeoutPolicy: { - budget: { source: 'flag' }, + budget: { + source: 'flag', + envelope: 'budget-plus-margin', + defaultBudgetMs: PREPARE_REQUEST_TIMEOUT_MS, + }, envelopeMs: PREPARE_REQUEST_TIMEOUT_MS, onTimeout: 'reset-daemon', }, diff --git a/src/core/command-descriptor/timeout-policy.ts b/src/core/command-descriptor/timeout-policy.ts index 9bf218cb99..dc26f6a7bb 100644 --- a/src/core/command-descriptor/timeout-policy.ts +++ b/src/core/command-descriptor/timeout-policy.ts @@ -16,6 +16,9 @@ export const INSTALL_REQUEST_TIMEOUT_MS = 180_000; // envelope below the command's declared base. export const REQUEST_TIMEOUT_BUDGET_MARGIN_MS = 30_000; +/** Keep both startup and its cleanup envelope within Node's signed 32-bit timer range. */ +export const MAX_STARTUP_TIMEOUT_MS = 2_147_483_647 - REQUEST_TIMEOUT_BUDGET_MARGIN_MS; + /** * How long a lease lifecycle provider may spend allocating one lease (cloud * device allocation: BrowserStack iOS ~45–90s, AWS remote access ~2 min to diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index 9424d9e9c9..fb1863f895 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -38,7 +38,10 @@ export type DaemonCommandTraits = Omit; * flag bounds a post-action wait, so the request must * also cover selector/action overhead). `defaultBudgetMs` * is used when the feature flag is present but the - * numeric timeout flag is omitted. + * numeric timeout flag is omitted. With + * `envelope: 'budget-plus-margin'`, the envelope covers + * the budget plus cleanup margin, without a settle gate + * and never shrinking below the base envelope. * - `'positional-parser'`— the budget travels inside the positionals; `parser` * extracts it (or returns null when none was given). * The client widens the envelope to @@ -46,7 +49,11 @@ export type DaemonCommandTraits = Omit; */ export type CommandTimeoutBudget = | { source: 'none' } - | { source: 'flag'; envelope?: 'bound' | 'widen'; defaultBudgetMs?: number } + | { + source: 'flag'; + envelope?: 'bound' | 'widen' | 'budget-plus-margin'; + defaultBudgetMs?: number; + } | { source: 'positional-parser'; parser: (positionals: string[]) => number | null }; /** diff --git a/src/daemon/__tests__/device-claim-admission.test.ts b/src/daemon/__tests__/device-claim-admission.test.ts index 8429519a58..707d4455db 100644 --- a/src/daemon/__tests__/device-claim-admission.test.ts +++ b/src/daemon/__tests__/device-claim-admission.test.ts @@ -8,8 +8,8 @@ import { providerRuntimeOwner, type DeviceBindingIntent, } from '@agent-device/contracts/platform-runtime'; -import { asAppError } from '@agent-device/kernel/errors'; -import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; +import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, @@ -411,3 +411,51 @@ test('the request scope claims only for commands whose descriptor declares trans expect(await claimsWhileBound('snapshot', stateDir)).toEqual([]); expect(inspectDeviceClaims({})).toEqual([]); }); + +test.for([true, false])( + 'prepare cleanup failure retains its transient claim only with the typed reason: %s', + async (typedFailure, { expect }) => { + const { root, stateDir } = setup(); + const failure = new AppError( + 'COMMAND_FAILED', + 'Canceled simulator boot cleanup failed', + typedFailure ? { reason: 'ios_boot_cleanup_failed', deviceId: IOS_SIMULATOR.id } : undefined, + ); + const scope = await createRequestExecutionScope({ + req: { + token: 't', + session: 'default', + command: 'prepare', + positionals: ['ios-runner'], + flags: {}, + }, + sessionStore: new SessionStore(path.join(stateDir, 'sessions')), + leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: unavailableDeviceRuntimeGateway, + platformRequestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + await expect( + scope.runAdmitted(async () => { + await scope.bindDevice(IOS_SIMULATOR, { required: [], preferred: [] }); + throw failure; + }), + ).rejects.toBe(failure); + await scope[Symbol.asyncDispose](); + const claims = inspectDeviceClaims({}); + expect(claims).toHaveLength(typedFailure ? 1 : 0); + if (!typedFailure) return; + expect(claims[0]?.claim?.abandonedAtMs).toEqual(expect.any(Number)); + const competitor = await acquireDeviceClaim({ + device: IOS_SIMULATOR, + session: 'competitor', + workspace: '/worktrees/competitor', + stateDir: path.join(root, 'competitor'), + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + expect(competitor.status).toBe('conflict'); + }, +); diff --git a/src/daemon/application-lifecycle-execution.ts b/src/daemon/application-lifecycle-execution.ts index a99c91e070..8f93f0a1e1 100644 --- a/src/daemon/application-lifecycle-execution.ts +++ b/src/daemon/application-lifecycle-execution.ts @@ -9,6 +9,7 @@ export function applicationLifecycleExecutionFromRequest( traceLogPath?: string, ): ApplicationLifecycleExecution { return { + startupDeadlineAtMs: req.internal?.startupDeadlineAtMs, requestId: req.meta?.requestId, logPath, traceLogPath, diff --git a/src/daemon/client/__tests__/daemon-client-timeout.test.ts b/src/daemon/client/__tests__/daemon-client-timeout.test.ts new file mode 100644 index 0000000000..c4c46ffefd --- /dev/null +++ b/src/daemon/client/__tests__/daemon-client-timeout.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from 'vitest'; +import { resolveDaemonRequestTimeoutMs } from '../daemon-client-timeout.ts'; + +test.each(['open', 'prepare'])( + '%s rejects a budget that overflows the cleanup envelope', + (command) => { + expect(() => + resolveDaemonRequestTimeoutMs({ + command, + session: 'cold-start', + positionals: [], + flags: { timeoutMs: 2_147_453_648 }, + }), + ).toThrow(/timeoutMs/); + expect( + resolveDaemonRequestTimeoutMs({ + command, + session: 'cold-start', + positionals: [], + flags: { timeoutMs: 2_147_453_647 }, + }), + ).toBe(2_147_483_647); + }, +); + +test.each([ + [undefined, 90_000], + [600_000, 630_000], + [5_000, 90_000], +])('open startup budget %s leaves cleanup margin in the client envelope', (timeoutMs, expected) => { + expect( + resolveDaemonRequestTimeoutMs({ + command: 'open', + session: 'cold-start', + positionals: ['Settings'], + flags: timeoutMs === undefined ? {} : { timeoutMs }, + }), + ).toBe(expected); +}); + +test.each([ + [undefined, 270_000], + [600_000, 630_000], + [5_000, 240_000], +])('prepare budget %s leaves cleanup margin in the client envelope', (timeoutMs, expected) => { + expect( + resolveDaemonRequestTimeoutMs({ + command: 'prepare', + session: 'cold-start', + positionals: ['ios-runner'], + flags: timeoutMs === undefined ? {} : { timeoutMs }, + }), + ).toBe(expected); +}); diff --git a/src/daemon/client/__tests__/daemon-client.test.ts b/src/daemon/client/__tests__/daemon-client.test.ts index 53475ad39a..97d165471e 100644 --- a/src/daemon/client/__tests__/daemon-client.test.ts +++ b/src/daemon/client/__tests__/daemon-client.test.ts @@ -479,7 +479,7 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid command: 'prepare', positionals: ['ios-runner'], }), - 240_000, + 270_000, ); assert.equal( resolveDaemonRequestTimeoutMs({ @@ -488,7 +488,7 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid positionals: ['ios-runner'], flags: { timeoutMs: 240_000 }, }), - 240_000, + 270_000, ); assert.equal(resolveDaemonRequestTimeoutMs({ ...base, command: 'test' }), undefined); }); diff --git a/src/daemon/client/daemon-client-timeout.ts b/src/daemon/client/daemon-client-timeout.ts index a58127da5d..2b38372081 100644 --- a/src/daemon/client/daemon-client-timeout.ts +++ b/src/daemon/client/daemon-client-timeout.ts @@ -1,11 +1,15 @@ import { AppError } from '@agent-device/kernel/errors'; +import { readOptionalInteger } from '@agent-device/contracts/command'; import { runCmdSync } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { resolveCommandTimeoutPolicy } from '../../core/command-descriptor/registry.ts'; -import { REQUEST_TIMEOUT_BUDGET_MARGIN_MS } from '../../core/command-descriptor/timeout-policy.ts'; +import { + MAX_STARTUP_TIMEOUT_MS, + REQUEST_TIMEOUT_BUDGET_MARGIN_MS, +} from '../../core/command-descriptor/timeout-policy.ts'; import type { CommandTimeoutBudget, CommandTimeoutPolicy, @@ -78,11 +82,17 @@ function resolveFlagBudgetTimeoutMs( flags: RequestFlags, ): number | undefined { if (policy.budget.source !== 'flag') return undefined; + if (policy.budget.envelope === 'budget-plus-margin') { + const budgetMs = + readOptionalInteger(flags ?? {}, 'timeoutMs', { min: 1, max: MAX_STARTUP_TIMEOUT_MS }) ?? + policy.budget.defaultBudgetMs; + return typeof budgetMs === 'number' ? widenToUserBudget(policy, budgetMs) : policy.envelopeMs; + } // 'widen' budgets (interaction --settle, #1101) bound an internal wait the // request must outlive after selector resolution/action overhead. They are // settle-gated for touch-command back-compat: a bare timeoutMs without // --settle was historically ignored. Plain 'bound' budgets (replay, - // prepare, snapshot) replace the envelope verbatim. + // snapshot) replace the envelope verbatim. if (policy.budget.envelope === 'widen') { return resolveWideningFlagBudget(policy, policy.budget, flags); } diff --git a/src/daemon/device-claim-admission.ts b/src/daemon/device-claim-admission.ts index 54d121fe31..cf97f2195b 100644 --- a/src/daemon/device-claim-admission.ts +++ b/src/daemon/device-claim-admission.ts @@ -5,6 +5,7 @@ import type { } from '@agent-device/contracts/platform-runtime'; import type { DeviceClaimPolicy } from '../core/command-descriptor/types.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { AppError } from '@agent-device/kernel/errors'; import { inspectAllocatorHeldDeviceClaim, requireAllocatorHeldDeviceClaim, @@ -13,6 +14,7 @@ import { decideAllocatorHeldAdmission, deviceClaimConflictError } from './device import { deviceClaimRuleForOwner } from './device-claim-rule.ts'; import { acquireTransientDeviceClaim, + abandonDeviceClaim, clearDeviceClaim, type DeviceClaimReconciler, type DeviceClaimSessionOwnership, @@ -42,6 +44,8 @@ export type DeviceClaimAdmission = AsyncDisposable & * `COMMAND_FAILED` when a managed local owner has no allocator-held claim. */ admit(device: DeviceInfo, owner: RuntimeOwnerRef, intent: DeviceBindingIntent): Promise; + /** Keeps acquired claims fenced when the task cannot confirm startup cleanup. */ + run(task: () => Promise): Promise; }>; export function createDeviceClaimAdmission(params: { @@ -54,6 +58,7 @@ export function createDeviceClaimAdmission(params: { // The caller admits once per device binding, so this only has to remember what // it took in order to give it back. const acquired: DeviceClaimSessionOwnership[] = []; + let cleanupUnconfirmed = false; /** * `none` is the one policy that touches no device state at all. Every other policy reaches the @@ -82,6 +87,16 @@ export function createDeviceClaimAdmission(params: { } return { + run: async (task) => { + try { + return await task(); + } catch (error) { + if (error instanceof AppError && error.details?.reason === 'ios_boot_cleanup_failed') { + cleanupUnconfirmed = true; + } + throw error; + } + }, admit: async (device, owner, intent) => { switch (deviceClaimRuleForOwner(owner)) { case 'none': @@ -102,7 +117,8 @@ export function createDeviceClaimAdmission(params: { [Symbol.asyncDispose]: async () => { for (const ownership of acquired.splice(0)) { try { - await clearDeviceClaim(ownership); + if (cleanupUnconfirmed) await abandonDeviceClaim(ownership); + else await clearDeviceClaim(ownership); } catch (error) { emitDiagnostic({ level: 'error', diff --git a/src/daemon/handlers/session-prepare.ts b/src/daemon/handlers/session-prepare.ts index ac2688a9fe..cc8a863624 100644 --- a/src/daemon/handlers/session-prepare.ts +++ b/src/daemon/handlers/session-prepare.ts @@ -1,7 +1,11 @@ import { prepareAppleRunnerRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { PREPARE_REQUEST_TIMEOUT_MS } from '../../core/command-descriptor/timeout-policy.ts'; +import { + MAX_STARTUP_TIMEOUT_MS, + PREPARE_REQUEST_TIMEOUT_MS, +} from '../../core/command-descriptor/timeout-policy.ts'; +import { readOptionalInteger } from '@agent-device/contracts/command'; import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { resolveRunnerLogicalLeaseContext } from '../lease-context.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; @@ -48,6 +52,7 @@ export async function handlePrepareCommand(params: { } const session = sessionStore.get(sessionName); + const timeoutMs = readPrepareIosRunnerTimeoutMs(req); const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.prepare, session, flags); if (guard) return guard; @@ -64,7 +69,7 @@ export async function handlePrepareCommand(params: { const startedAtMs = Date.now(); const result = await admission.runtime.operations.prepareAppleRunner({ - timeoutMs: readPrepareIosRunnerTimeoutMs(req), + timeoutMs, execution: { requestId: req.meta?.requestId, logPath, @@ -84,10 +89,10 @@ export async function handlePrepareCommand(params: { } function readPrepareIosRunnerTimeoutMs(req: DaemonRequest): number { - const value = req.flags?.timeoutMs; - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? value - : PREPARE_REQUEST_TIMEOUT_MS; + return ( + readOptionalInteger(req.flags ?? {}, 'timeoutMs', { min: 1, max: MAX_STARTUP_TIMEOUT_MS }) ?? + PREPARE_REQUEST_TIMEOUT_MS + ); } function prepareIosRunnerResponseData( diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 57f3a83bdc..4831b2eff7 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -241,9 +241,11 @@ export async function createRequestExecutionScope(params: { providerAppCatalog: params.providerAppCatalog, }); scope.req = scopedReq; - return isHumanControlMutation(scopedReq) - ? await leaseRegistry.runDeviceMutation(scopedReq.internal?.admittedLease, task) - : await task(); + const execute = async () => + isHumanControlMutation(scopedReq) + ? await leaseRegistry.runDeviceMutation(scopedReq.internal?.admittedLease, task) + : await task(); + return claimAdmission ? await claimAdmission.run(execute) : await execute(); }, runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-claim-rollback.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-claim-rollback.test.ts new file mode 100644 index 0000000000..db40f3e24a --- /dev/null +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-claim-rollback.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { rollbackNewSessionClaim } from '../session-open-claim-rollback.ts'; +import { makeSessionStore } from './session-open-runtime.fixtures.ts'; +import { abandonDeviceClaim, clearDeviceClaim } from '../../../device-claims.ts'; + +vi.mock('../../../device-claims.ts', () => ({ + abandonDeviceClaim: vi.fn(async () => 'abandoned'), + clearDeviceClaim: vi.fn(async () => 'deleted'), +})); + +beforeEach(() => vi.clearAllMocks()); + +test.each([ + [new AppError('COMMAND_FAILED', 'cleanup failed', { reason: 'ios_boot_cleanup_failed' }), true], + [new AppError('COMMAND_FAILED', 'cleanup failed'), false], + [undefined, false], +] as const)( + 'claim rollback classifies preparation cleanup by typed reason %#', + async (error, retain) => { + const ownership = { + deviceKey: 'apple:ios:sim-1', + ownerToken: 'test-owner', + ownerPid: process.pid, + ownerStartTime: null, + }; + await rollbackNewSessionClaim({ + ownership, + effects: { mayHaveStarted: false }, + sessionName: 'cold-start', + sessionStore: makeSessionStore(), + error, + }); + expect(abandonDeviceClaim).toHaveBeenCalledTimes(retain ? 1 : 0); + expect(clearDeviceClaim).toHaveBeenCalledTimes(retain ? 0 : 1); + }, +); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-deadline.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-deadline.test.ts new file mode 100644 index 0000000000..d8b2880c29 --- /dev/null +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-deadline.test.ts @@ -0,0 +1,104 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { + clearRequestAbortRegistration, + registerRequestAbort, +} from '@agent-device/host-kit/request'; +import { AppError } from '@agent-device/kernel/errors'; +import { withOpenStartupDeadline } from '../session-open-deadline.ts'; +import type { DaemonRequest } from '../../../types.ts'; +import { MAX_STARTUP_TIMEOUT_MS } from '../../../../core/command-descriptor/timeout-policy.ts'; + +const req: DaemonRequest = { + token: 'test', + session: 'cold-start', + command: 'open', + positionals: ['Settings'], + flags: { timeoutMs: 600_000 }, + meta: { requestId: 'cold-start-test' }, +}; + +afterEach(() => vi.useRealTimers()); + +test.each([0, -1, 1.5, Number.NaN, MAX_STARTUP_TIMEOUT_MS + 1])( + 'rejects an invalid startup timer before executing device work: %s', + async (timeoutMs) => { + const open = vi.fn(); + await expect( + withOpenStartupDeadline({ ...req, flags: { timeoutMs } }, open), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(open).not.toHaveBeenCalled(); + }, +); + +test('startup cancels only its request and waits for cleanup before reporting timeout', async () => { + vi.useFakeTimers(); + const registration = registerRequestAbort(req.meta?.requestId); + const other = registerRequestAbort('other-request'); + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + let settled = false; + try { + const pending = withOpenStartupDeadline(req, async (timed) => { + expect(timed.internal?.startupDeadlineAtMs).toBe(Date.now() + 600_000); + await new Promise((_, reject) => + registration?.controller.signal.addEventListener( + 'abort', + () => reject(registration.controller.signal.reason), + { once: true }, + ), + ).catch(async (error) => { + await cleanup; + throw error; + }); + return { ok: true, data: {} }; + }).then((response) => { + settled = true; + return response; + }); + await vi.advanceTimersByTimeAsync(600_000); + expect(registration?.controller.signal.aborted).toBe(true); + expect(other?.controller.signal.aborted).toBe(false); + expect(settled).toBe(false); + finishCleanup(); + expect(await pending).toMatchObject({ + ok: false, + error: { code: 'COMMAND_FAILED', details: { reason: 'startup_timeout', timeoutMs: 600_000 } }, + }); + } finally { + clearRequestAbortRegistration(registration); + clearRequestAbortRegistration(other); + } +}); + +test('completed startup clears its timer and preserves the session response', async () => { + vi.useFakeTimers(); + const registration = registerRequestAbort(req.meta?.requestId); + try { + expect( + await withOpenStartupDeadline(req, async () => ({ + ok: true, + data: { session: 'cold-start' }, + })), + ).toEqual({ ok: true, data: { session: 'cold-start' } }); + await vi.advanceTimersByTimeAsync(600_001); + expect(registration?.controller.signal.aborted).toBe(false); + } finally { + clearRequestAbortRegistration(registration); + } +}); + +test('startup preserves cleanup failures instead of claiming timeout cleanup succeeded', async () => { + vi.useFakeTimers(); + const failure = new AppError('COMMAND_FAILED', 'cleanup failed', { + reason: 'ios_boot_cleanup_failed', + }); + const pending = withOpenStartupDeadline(req, async () => { + await new Promise((resolve) => setTimeout(resolve, 600_001)); + throw failure; + }); + const rejection = expect(pending).rejects.toBe(failure); + await vi.advanceTimersByTimeAsync(600_001); + await rejection; +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 3b89c0b1e4..1e5a8af773 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -15,6 +15,19 @@ vi.mock('../../../../core/dispatch-resolve.ts', async (importOriginal) => { }; }); vi.mock('../../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../../../platform-runtime-apple-application-tools.ts', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../../../platform-runtime-apple-application-tools.ts') + >(); + return { + ...actual, + createAppleApplicationTools: () => ({ + ...actual.createAppleApplicationTools(), + prewarmRunnerSession: vi.fn(async () => {}), + }), + }; +}); vi.mock('../../../../platform-runtime-runtime-hints.ts', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/daemon/session-lifecycle/internal/session-open-claim-rollback.ts b/src/daemon/session-lifecycle/internal/session-open-claim-rollback.ts new file mode 100644 index 0000000000..494879af5a --- /dev/null +++ b/src/daemon/session-lifecycle/internal/session-open-claim-rollback.ts @@ -0,0 +1,32 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { + abandonDeviceClaim, + clearDeviceClaim, + type DeviceClaimSessionOwnership, +} from '../../device-claims.ts'; +import type { SessionStore } from '../../session-store.ts'; + +export async function rollbackNewSessionClaim(params: { + ownership: DeviceClaimSessionOwnership | undefined; + effects: { mayHaveStarted: boolean }; + sessionName: string; + sessionStore: SessionStore; + error?: unknown; +}): Promise { + const { ownership, effects, sessionName, sessionStore, error } = params; + if (!ownership) return; + const cleanupFailed = + error instanceof AppError && error.details?.reason === 'ios_boot_cleanup_failed'; + if (!effects.mayHaveStarted && !cleanupFailed) { + await clearDeviceClaim(ownership); + return; + } + if (sessionStore.get(sessionName)?.deviceClaim?.ownerToken === ownership.ownerToken) return; + const outcome = await abandonDeviceClaim(ownership); + emitDiagnostic({ + level: 'warn', + phase: 'device_claim_open_effects_unconfirmed', + data: { deviceKey: ownership.deviceKey, outcome }, + }); +} diff --git a/src/daemon/session-lifecycle/internal/session-open-deadline.ts b/src/daemon/session-lifecycle/internal/session-open-deadline.ts new file mode 100644 index 0000000000..971ff5018c --- /dev/null +++ b/src/daemon/session-lifecycle/internal/session-open-deadline.ts @@ -0,0 +1,45 @@ +import { markRequestCanceled } from '@agent-device/host-kit/request'; +import { readOptionalInteger } from '@agent-device/contracts/command'; +import { MAX_STARTUP_TIMEOUT_MS } from '../../../core/command-descriptor/timeout-policy.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { errorResponse } from '../../response.ts'; + +/** Cancels startup inside the daemon, leaving the client envelope for owned cleanup. */ +export async function withOpenStartupDeadline( + req: DaemonRequest, + open: (req: DaemonRequest) => Promise, +): Promise { + const timeoutMs = readOptionalInteger(req.flags ?? {}, 'timeoutMs', { + min: 1, + max: MAX_STARTUP_TIMEOUT_MS, + }); + if (timeoutMs === undefined) return await open(req); + const timedRequest = { + ...req, + internal: { ...req.internal, startupDeadlineAtMs: Date.now() + timeoutMs }, + }; + let expired = false; + const timer = setTimeout(() => { + expired = true; + markRequestCanceled(req.meta?.requestId); + }, timeoutMs); + try { + const response = await open(timedRequest); + return expired && !response.ok ? startupTimeoutResponse(timeoutMs) : response; + } catch (error) { + if (!expired) throw error; + if (error instanceof AppError && error.details?.reason === 'ios_boot_cleanup_failed') + throw error; + return startupTimeoutResponse(timeoutMs); + } finally { + clearTimeout(timer); + } +} + +function startupTimeoutResponse(timeoutMs: number): DaemonResponse { + return errorResponse('COMMAND_FAILED', 'Application startup deadline exceeded', { + reason: 'startup_timeout', + timeoutMs, + }); +} diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index ac1aeb2329..e6f6ce6dec 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -46,9 +46,7 @@ import { import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; import { - abandonDeviceClaim, acquireDeviceClaim, - clearDeviceClaim, type DeviceClaimAcquireResult, type DeviceClaimSessionOwnership, type DeviceClaimReconciler, @@ -59,6 +57,7 @@ import { } from '../../device-claim-conflict.ts'; import { requireAllocatorHeldDeviceClaim } from '../../device-claim-allocator.ts'; import { deviceClaimRuleForOwner } from '../../device-claim-rule.ts'; +import { rollbackNewSessionClaim } from './session-open-claim-rollback.ts'; type OpenTiming = { totalDurationMs?: number; @@ -450,12 +449,13 @@ export async function openNewSessionWithDeviceClaim(params: { if (ownerClaim.status === 'refused') return ownerClaim.response; const deviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.ownership : undefined; const effects: NewSessionOpenEffects = { mayHaveStarted: false }; - const rollbackClaim = async () => + const rollbackClaim = async (error?: unknown) => await rollbackNewSessionClaim({ ownership: deviceClaim, effects, sessionName, sessionStore, + error, }); try { const details = await prepareOpenCommandDetails({ @@ -506,28 +506,7 @@ export async function openNewSessionWithDeviceClaim(params: { if (!response.ok) await rollbackClaim(); return response; } catch (error) { - await rollbackClaim(); + await rollbackClaim(error); throw error; } } - -async function rollbackNewSessionClaim(params: { - ownership: DeviceClaimSessionOwnership | undefined; - effects: NewSessionOpenEffects; - sessionName: string; - sessionStore: SessionStore; -}): Promise { - const { ownership, effects, sessionName, sessionStore } = params; - if (!ownership) return; - if (!effects.mayHaveStarted) { - await clearDeviceClaim(ownership); - return; - } - if (sessionStore.get(sessionName)?.deviceClaim?.ownerToken === ownership.ownerToken) return; - const outcome = await abandonDeviceClaim(ownership); - emitDiagnostic({ - level: 'warn', - phase: 'device_claim_open_effects_unconfirmed', - data: { deviceKey: ownership.deviceKey, outcome }, - }); -} diff --git a/src/daemon/session-lifecycle/internal/session-open-prepare.ts b/src/daemon/session-lifecycle/internal/session-open-prepare.ts index 6249344f3e..02b22b7f46 100644 --- a/src/daemon/session-lifecycle/internal/session-open-prepare.ts +++ b/src/daemon/session-lifecycle/internal/session-open-prepare.ts @@ -165,6 +165,7 @@ export async function prepareOpenCommandDetails(params: { prewarmRunnerOnColdBoot: surface === 'app' && Boolean(openTarget) && !isDeepLinkTarget(openTarget ?? ''), execution: { + startupDeadlineAtMs: req.internal?.startupDeadlineAtMs, requestId: req.meta?.requestId, logPath, traceLogPath: existingSession?.trace?.outPath, diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index becc581c41..8b53d100aa 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -1,4 +1,5 @@ import { resolveTargetDeviceSelection } from '../../../core/dispatch-resolve.ts'; +import { withOpenStartupDeadline } from './session-open-deadline.ts'; import { openApplicationRuntimeUse, openApplicationWithRuntimeHintApplyAndClearUse, @@ -307,6 +308,13 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise { + return await withOpenStartupDeadline( + params.req, + async (req) => await openWithInitialSnapshot({ ...params, req }), + ); +} + +async function openWithInitialSnapshot(params: SessionOpenCommandInput): Promise { const openResponse = await handleOpenCommand(params); if (!openResponse.ok || params.req.flags?.foreground !== true) return openResponse; return await composeOpenWithInitialSnapshot({ diff --git a/src/daemon/types.ts b/src/daemon/types.ts index babdfafbb1..bef7548b83 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -50,6 +50,7 @@ export type DaemonOpenLifecycle = { }; type DaemonRequestInternal = { + startupDeadlineAtMs?: number; publicNetworkOnly?: true; openLifecycle?: DaemonOpenLifecycle; /** diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 82ef6be640..4dfa1c8747 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -25,6 +25,24 @@ export function createAppleApplicationTools(): AppleApplicationTools { propagateError, options?: AppleRunnerSessionPrewarmOptions, ) => { + if (execution.startupDeadlineAtMs !== undefined) { + const { Deadline } = await import('@agent-device/host-kit/retry'); + const { prepareIosRunner } = await import('@agent-device/platform-apple/runner/operations'); + const timeoutMs = execution.startupDeadlineAtMs - Date.now(); + if (timeoutMs <= 0) { + throw new AppError('COMMAND_FAILED', 'Application startup deadline exceeded', { + reason: 'startup_timeout', + }); + } + await prepareIosRunner(device, { + ...appleRunnerOptions(execution, signal), + buildTimeoutMs: timeoutMs, + startupTimeoutMs: timeoutMs, + healthTimeoutMs: timeoutMs, + prepareDeadline: Deadline.fromTimeoutMs(timeoutMs), + }); + return; + } const { prewarmIosRunnerSession } = await import('@agent-device/platform-apple/runner/operations'); await prewarmIosRunnerSession(device, { diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index 7a257c1eee..35c0f29afb 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -15,6 +15,17 @@ agent-device close The implicit `default` session is scoped to the caller's git worktree or current working directory. Independent agents in different worktrees do not attach to each other's default session. + +For a never-booted iOS simulator, give `open` a startup budget that covers first-boot initialization: + +```bash +agent-device open Settings --platform ios --udid --timeout 600000 +``` + +The device claim stays held through boot and Apple runner preparation, then belongs to the session +until `close`. With `--timeout`, `open` waits for runner readiness before returning. The client +allows cleanup time beyond this startup budget. Omitting the flag keeps the default startup behavior. + When a session is established, human output includes a `Session state: ` line and JSON output includes `sessionStateDir`; this is the per-session artifact directory that can be inspected or removed after the run. JSON output also includes `runnerLogPath` and `requestLogPath` when available. Session artifact directories contain per-run evidence for concurrent agents: