From f40d02dd2642af18148f4e2dfca21ac8a9d3ff44 Mon Sep 17 00:00:00 2001 From: Huangshuo Kuang <141250392+kkkhs@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:41:03 +0000 Subject: [PATCH 01/18] fix(timeouts): prove install request envelope --- .../src/descriptors/session-operations.ts | 3 +- .../command-registry/src/timeout-policy.ts | 9 +- .../contracts/src/platform-runtime-host.ts | 3 +- .../platform-android/src/adb-transport.ts | 2 +- .../src/deployment/native.test.ts | 13 +- .../platform-android/src/deployment/native.ts | 28 +- packages/platform-apple/src/core/config.ts | 1 + .../platform-apple/src/deployment/runtime.ts | 5 +- packages/provision-kit/src/install-source.ts | 6 +- .../command-descriptor-timeout-policy.test.ts | 15 +- .../command-tools-timeout-note.test.ts | 3 +- src/platform-runtime-android-tool-host.ts | 11 +- .../install-timeout-ledger.test.ts | 522 ++++++++++++++++++ 13 files changed, 593 insertions(+), 28 deletions(-) create mode 100644 test/integration/provider-scenarios/install-timeout-ledger.test.ts diff --git a/packages/command-registry/src/descriptors/session-operations.ts b/packages/command-registry/src/descriptors/session-operations.ts index 3656f99f7e..d72780f1c2 100644 --- a/packages/command-registry/src/descriptors/session-operations.ts +++ b/packages/command-registry/src/descriptors/session-operations.ts @@ -17,6 +17,7 @@ import { ownerFilesEnabled, type RawCommandDescriptor, } from '../descriptor-traits.ts'; +import { INSTALL_SOURCE_REQUEST_TIMEOUT_MS } from '../timeout-policy.ts'; import { INSTALL_TIMEOUT_POLICY } from '../descriptor-timeout-policies.ts'; import { deployAppUse, @@ -284,7 +285,7 @@ export const SESSION_OPERATION_COMMAND_DESCRIPTORS = [ recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, - timeoutPolicy: INSTALL_TIMEOUT_POLICY, + timeoutPolicy: { ...INSTALL_TIMEOUT_POLICY, envelopeMs: INSTALL_SOURCE_REQUEST_TIMEOUT_MS }, batchable: false, }, { diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index d8a17a3355..3b10bb03da 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -6,9 +6,12 @@ import type { CommandTimeoutBudget, CommandTimeoutPolicy } from './types.ts'; const DAEMON_REQUEST_TIMEOUT_MS = 90_000; -// Keep this above the longest platform install subprocess timeout so the client -// envelope does not abort a still-progressing device install first. -export const INSTALL_REQUEST_TIMEOUT_MS = 180_000; +// Covers the install/reinstall/uninstall route's worst-case ledger plus the daemon-result margin. +// Proven by the ledger test: `test/integration/provider-scenarios/install-timeout-ledger.test.ts`. +export const INSTALL_REQUEST_TIMEOUT_MS = 330_000; + +// Adds room for install-from-source URL materialization before the same platform install ledgers. +export const INSTALL_SOURCE_REQUEST_TIMEOUT_MS = 420_000; // Margin over a daemon-side budget so the daemon's own timeout result (with // diagnostics) wins the race against the client envelope. Never shrinks the diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index 9c78d43b92..a939c654a7 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -107,7 +107,7 @@ export type AndroidToolHost = Readonly<{ installPackage( device: DeviceInfo, packagePath: string, - options: Readonly<{ replace: boolean }>, + options: Readonly<{ replace: boolean; timeoutMs?: number }>, signal?: AbortSignal, ): Promise; /** Returns false when the selected transport has no bundle installer. */ @@ -115,6 +115,7 @@ export type AndroidToolHost = Readonly<{ device: DeviceInfo, bundlePath: string, mode: string, + options?: Readonly<{ timeoutMs?: number }>, signal?: AbortSignal, ): Promise; }>; diff --git a/packages/platform-android/src/adb-transport.ts b/packages/platform-android/src/adb-transport.ts index 07ea27d741..a422665006 100644 --- a/packages/platform-android/src/adb-transport.ts +++ b/packages/platform-android/src/adb-transport.ts @@ -135,7 +135,7 @@ export type AndroidAdbInstaller = ( export type AndroidBundleInstaller = ( bundlePath: string, - options: Readonly<{ mode: string; signal?: AbortSignal }>, + options: Readonly<{ mode: string; signal?: AbortSignal; timeoutMs?: number }>, ) => Promise; export type AndroidTextInputAction = 'type' | 'fill'; diff --git a/packages/platform-android/src/deployment/native.test.ts b/packages/platform-android/src/deployment/native.test.ts index 0efffd5b1d..7c9aa6755e 100644 --- a/packages/platform-android/src/deployment/native.test.ts +++ b/packages/platform-android/src/deployment/native.test.ts @@ -39,6 +39,7 @@ function hostFixture(options: { bundletool?: boolean; bundletoolJar?: string } = executable: 'adb-install', args: [packagePath], replace: options.replace, + timeoutMs: options.timeoutMs, }, signal, ), @@ -73,6 +74,7 @@ test('constructs a signal-bound adb install in the Android package', async () => executable: 'adb-install', args: ['/tmp/app.apk'], replace: true, + timeoutMs: 120_000, }), signal, ); @@ -98,9 +100,14 @@ test('owns bundletool command construction and temporary output cleanup', async await installAndroidArtifact(host, device, '/tmp/app.aab', 'com.example.app', signal); - expect(run.mock.calls.map(([request]) => request.args[0])).toEqual([ - 'build-apks', - 'install-apks', + expect( + run.mock.calls.map(([request]) => ({ + command: request.args[0], + timeoutMs: request.timeoutMs, + })), + ).toEqual([ + { command: 'build-apks', timeoutMs: 120_000 }, + { command: 'install-apks', timeoutMs: 120_000 }, ]); expect(run.mock.calls.every(([, actualSignal]) => actualSignal === signal)).toBe(true); expect(dispose).toHaveBeenCalledOnce(); diff --git a/packages/platform-android/src/deployment/native.ts b/packages/platform-android/src/deployment/native.ts index 39dcffc5c3..68e9ab8e82 100644 --- a/packages/platform-android/src/deployment/native.ts +++ b/packages/platform-android/src/deployment/native.ts @@ -5,6 +5,9 @@ import { deviceShellArgv, type ShellWord } from '@agent-device/kernel/device-she import { AppError } from '@agent-device/kernel/errors'; import path from 'node:path'; +const ANDROID_APP_INSTALL_TIMEOUT_MS = 120_000; +const ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS = 15_000; + export async function installAndroidArtifact( host: PlatformRuntimeHost, device: DeviceInfo, @@ -19,7 +22,7 @@ export async function installAndroidArtifact( const result = await host.androidTools.installPackage( device, installablePath, - { replace: true }, + { replace: true, timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS }, signal, ); assertCommandSuccess(result, 'adb install failed'); @@ -36,7 +39,13 @@ export async function uninstallAndroidPackage( packageName: string, signal: AbortSignal, ): Promise { - const result = await runAdb(host, device, ['uninstall', packageName], signal); + const result = await runAdb( + host, + device, + ['uninstall', packageName], + signal, + ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, + ); if (result.exitCode === 0) return; const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (output.includes('unknown package') || output.includes('not installed')) return; @@ -100,7 +109,16 @@ async function installAndroidBundle( bundlePath: string, signal: AbortSignal, ): Promise { - if (await host.androidTools.installBundle(device, bundlePath, 'universal', signal)) return; + if ( + await host.androidTools.installBundle( + device, + bundlePath, + 'universal', + { timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS }, + signal, + ) + ) + return; const bundletool = await host.commands.which('bundletool'); const jar = host.androidDeployment.bundletoolJar; if (!bundletool && !jar) { @@ -131,6 +149,7 @@ async function installAndroidBundle( '--mode', 'universal', ], + timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS, }, signal, ), @@ -141,6 +160,7 @@ async function installAndroidBundle( { executable, args: [...prefix, 'install-apks', '--apks', apksPath, '--device-id', device.id], + timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS, }, signal, ), @@ -189,7 +209,7 @@ async function runAdb( device: DeviceInfo, args: readonly string[], signal: AbortSignal, - timeoutMs = 15_000, + timeoutMs = ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, ) { return await host.androidTools.runAdb(device, args, { timeoutMs, allowFailure: true }, signal); } diff --git a/packages/platform-apple/src/core/config.ts b/packages/platform-apple/src/core/config.ts index 161194d688..898dfe8fd2 100644 --- a/packages/platform-apple/src/core/config.ts +++ b/packages/platform-apple/src/core/config.ts @@ -9,6 +9,7 @@ export const IOS_DEVICECTL_TIMEOUT_MS = 20_000; export const IOS_DEVICECTL_MIN_TIMEOUT_SECONDS = 5; export const IOS_DEVICE_INSTALL_TIMEOUT_MS = 120_000; +export const IOS_APP_UNINSTALL_TIMEOUT_MS = 20_000; export const IOS_SIMULATOR_SHOW_TIMEOUT_MS = 10_000; diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index a1439c9c0d..5e2daa4d6c 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -18,7 +18,7 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { requireExecSuccess } from '@agent-device/host-kit/command'; import { isMissingAppErrorOutput } from '../core/physical-device-apps.ts'; -import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; +import { IOS_APP_UNINSTALL_TIMEOUT_MS, IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -178,6 +178,7 @@ async function installAppleApp( ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['install', device.id, installablePath]), + timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, } : { tool: 'devicectl', @@ -202,10 +203,12 @@ async function uninstallAppleApp( ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), + timeoutMs: IOS_APP_UNINSTALL_TIMEOUT_MS, } : { tool: 'devicectl', args: ['device', 'uninstall', 'app', '--device', device.id, bundleId], + timeoutMs: IOS_APP_UNINSTALL_TIMEOUT_MS, }, signal, `Apple app uninstall failed for ${bundleId}`, diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 136e68e94a..e4d058f9d7 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -45,7 +45,7 @@ const INTERNAL_ARCHIVE_EXTENSIONS = ['.zip', '.tar', '.tar.gz', '.tgz'] as const * @public Archive extensions accepted by install-source resolution. */ export const ARCHIVE_EXTENSIONS = Object.freeze([...INTERNAL_ARCHIVE_EXTENSIONS] as const); -const DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; +export const INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; export async function materializeInstallablePath( options: MaterializeInstallableOptions, @@ -124,7 +124,7 @@ async function downloadToTempFile( if (requestSignal?.aborted) { throw createRequestCanceledError(); } - const timeoutSignal = AbortSignal.timeout(DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout(INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; try { return await downloadInstallSource({ @@ -139,7 +139,7 @@ async function downloadToTempFile( error, requestSignal, timeoutSignal, - DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS, + INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS, ); } } diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index d47866543d..a5ca799fee 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -145,10 +145,11 @@ test('request envelopes deviating from the default are bounded, reviewed sets', const EXPECTED_ENVELOPES: Record = { // prepare: daemon-side runner budget (PREPARE_STARTUP_BUDGET_MS) plus the daemon-result margin. prepare: 270_000, - install: 180_000, - reinstall: 180_000, - uninstall: 180_000, - install_source: 180_000, + install: 330_000, + reinstall: 330_000, + uninstall: 330_000, + 'install-from-source': 420_000, + install_source: 420_000, // longpress: proven by the worst-case ledger test in // test/integration/provider-scenarios/android-longpress-ledger.test.ts. longpress: 320_000, @@ -311,15 +312,15 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('install'), { ...base }), - 180_000, + 330_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('reinstall'), { ...base }), - 180_000, + 330_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('install_source'), { ...base }), - 180_000, + 420_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { diff --git a/src/mcp/__tests__/command-tools-timeout-note.test.ts b/src/mcp/__tests__/command-tools-timeout-note.test.ts index 228ae3309e..eb71f2e688 100644 --- a/src/mcp/__tests__/command-tools-timeout-note.test.ts +++ b/src/mcp/__tests__/command-tools-timeout-note.test.ts @@ -37,5 +37,6 @@ test('every MCP tool description declares its timeout envelope', () => { test('the declared envelopes carry the known tier values', () => { const descriptions = new Map(listCommandTools().map((tool) => [tool.name, tool.description])); assert.match(descriptions.get('devices') ?? '', / Times out after 90s\.$/); - assert.match(descriptions.get('install') ?? '', / Times out after 180s\.$/); + assert.match(descriptions.get('install') ?? '', / Times out after 330s\.$/); + assert.match(descriptions.get('install-from-source') ?? '', / Times out after 420s\.$/); }); diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index c05b0e74c5..a94be05326 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -44,9 +44,14 @@ export function createAndroidToolHost(): AndroidToolHost { const { resolveAndroidAdbProvider } = await loadAndroidMechanics(); const provider = resolveAndroidAdbProvider(device); const result = provider.install - ? await provider.install(packagePath, { replace: options.replace, signal }) + ? await provider.install(packagePath, { + replace: options.replace, + signal, + timeoutMs: options.timeoutMs, + }) : await provider.exec(['install', ...(options.replace ? ['-r'] : []), packagePath], { signal, + timeoutMs: options.timeoutMs, }); return { stdout: result.stdout, @@ -54,11 +59,11 @@ export function createAndroidToolHost(): AndroidToolHost { exitCode: result.exitCode, }; }, - installBundle: async (device, bundlePath, mode, signal) => { + installBundle: async (device, bundlePath, mode, options, signal) => { const { resolveAndroidAdbProvider } = await loadAndroidMechanics(); const installer = resolveAndroidAdbProvider(device).installBundle; if (!installer) return false; - await installer(bundlePath, { mode, signal }); + await installer(bundlePath, { mode, signal, timeoutMs: options?.timeoutMs }); return true; }, }); diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts new file mode 100644 index 0000000000..677760b705 --- /dev/null +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -0,0 +1,522 @@ +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { + withCommandExecutorOverride, + type CommandExecutorOverride, +} from '@agent-device/host-kit/command'; +import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS } from '@agent-device/provision-kit/install-source'; +import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; +import assert from 'node:assert/strict'; +import dns from 'node:dns/promises'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { test, vi } from 'vitest'; +import { + PROVIDER_SCENARIO_ANDROID, + PROVIDER_SCENARIO_IOS_DEVICE, + PROVIDER_SCENARIO_IOS_SIMULATOR, + createDemoIosApp, +} from './fixtures.ts'; +import { + createProviderScenarioHarness, + type ClosableProviderScenarioResource, + type ProviderScenarioHarness, +} from './harness.ts'; + +type InstallCommand = 'install' | 'reinstall' | 'install_source'; + +type InstallLedgerCall = Readonly<{ + tool: 'adb' | 'adb-install' | 'bundletool' | 'download' | 'open' | 'simctl' | 'devicectl'; + args: readonly string[]; + timeoutMs: number; +}>; + +type InstallRoute = + | 'iOS physical device' + | 'iOS simulator' + | 'Android APK' + | 'Android AAB fallback'; + +type InstallLedgerClock = Readonly<{ + advanceByLedgerTimeout(timeoutMs: number): void; +}>; + +type InstallLedgerScenario = ClosableProviderScenarioResource & + Readonly<{ + appPath: string; + clock: InstallLedgerClock; + daemon: ProviderScenarioHarness; + ledger: InstallLedgerCall[]; + selection: Readonly< + { platform: 'android'; serial: string } | { platform: 'ios'; udid: string } + >; + }>; + +const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; +// The native deployment unit asserts this is the timeout passed to bundletool; the fake +// executable can only report argv back across the daemon HTTP boundary. +const ANDROID_BUNDLETOOL_TIMEOUT_MS = 120_000; +let nextAppleScenarioId = 0; + +for (const route of [ + 'iOS physical device', + 'iOS simulator', + 'Android APK', + 'Android AAB fallback', +] as const) { + test(`install-family request envelope covers the ${route} worst-case ledger`, async () => { + for (const command of ['install', 'reinstall', 'install_source'] as const) { + await withInstallLedgerScenario(route, async (scenario) => { + await runInstallCommand(scenario, command); + assertInstallLedgerPhaseCoverage(route, command, scenario.ledger); + assertLedgerFitsEnvelope(route, command, scenario.ledger); + }); + } + }, 20_000); +} + +test('install_source request envelope covers URL download before the Android AAB fallback ledger', async () => { + await withInstallLedgerScenario('Android AAB fallback', async (scenario) => { + await runInstallSourceFromUrlCommand(scenario); + assertInstallLedgerPhaseCoverage('Android AAB fallback', 'install_source', scenario.ledger); + assert.ok( + scenario.ledger.some((call) => call.tool === 'download'), + `install_source URL ledger is missing the download phase.\nLedger:\n${JSON.stringify( + scenario.ledger, + null, + 2, + )}`, + ); + assertLedgerFitsEnvelope('Android AAB fallback URL source', 'install_source', scenario.ledger); + }); +}, 20_000); + +async function withInstallLedgerScenario( + route: InstallRoute, + run: (scenario: InstallLedgerScenario) => Promise, +): Promise { + let virtualNowMs = 1_700_000_000_000; + const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => virtualNowMs); + const clock: InstallLedgerClock = { + advanceByLedgerTimeout: (timeoutMs) => { + virtualNowMs += Math.max(0, timeoutMs - 1); + }, + }; + const scenario = isAndroidRoute(route) + ? await createAndroidScenario(route, clock) + : await createIosScenario(route, clock); + try { + return await run(scenario); + } finally { + try { + await scenario.close(); + } finally { + dateNow.mockRestore(); + } + } +} + +async function runInstallCommand( + scenario: InstallLedgerScenario, + command: InstallCommand, +): Promise { + const client = scenario.daemon.client(); + if (command === 'install') { + await client.apps.install({ + app: 'com.example.demo', + appPath: scenario.appPath, + ...scenario.selection, + }); + return; + } + if (command === 'reinstall') { + await client.apps.reinstall({ + app: 'com.example.demo', + appPath: scenario.appPath, + ...scenario.selection, + }); + return; + } + await client.apps.installFromSource({ + source: { kind: 'path', path: scenario.appPath }, + ...scenario.selection, + }); +} + +async function runInstallSourceFromUrlCommand(scenario: InstallLedgerScenario): Promise { + const lookupMock = vi + .spyOn(dns, 'lookup') + .mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + const requestMock = vi + .spyOn(networkTransport, 'requestApprovedUrl') + .mockImplementation(async (request): Promise => { + recordLedgerCall( + scenario.ledger, + 'download', + [request.url.toString()], + INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS, + scenario.clock, + ); + return { + statusCode: 200, + headers: {}, + body: Readable.from([fs.readFileSync(scenario.appPath)]), + close: async () => {}, + }; + }); + try { + await scenario.daemon.client().apps.installFromSource({ + source: { kind: 'url', url: 'https://artifacts.example.com/Demo.aab' }, + ...scenario.selection, + }); + } finally { + requestMock.mockRestore(); + lookupMock.mockRestore(); + } +} + +async function createIosScenario( + route: 'iOS physical device' | 'iOS simulator', + clock: InstallLedgerClock, +): Promise { + const ledger: InstallLedgerCall[] = []; + const scenarioId = ++nextAppleScenarioId; + const device = + route === 'iOS physical device' + ? { + ...PROVIDER_SCENARIO_IOS_DEVICE, + id: `${PROVIDER_SCENARIO_IOS_DEVICE.id}-${scenarioId}`, + iosPhysicalDeviceBackend: 'coredevice' as const, + } + : { + ...PROVIDER_SCENARIO_IOS_SIMULATOR, + id: `sim-install-ledger-${scenarioId}`, + booted: false, + }; + const { tempRoot, appPath } = createDemoIosApp('agent-device-install-ledger-ios-'); + const commandOverride = createInstallLedgerHostCommandOverride(ledger, clock); + const daemon = await withCommandExecutorOverride( + commandOverride, + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [device], + appleToolProvider: () => + createInstallLedgerAppleToolProvider(route, device.id, ledger, clock), + }), + ); + return { + appPath, + clock, + daemon, + ledger, + selection: { platform: 'ios', udid: device.id }, + close: async () => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + await daemon.close(); + }, + }; +} + +function createInstallLedgerAppleToolProvider( + route: 'iOS physical device' | 'iOS simulator', + deviceId: string, + ledger: InstallLedgerCall[], + clock: InstallLedgerClock, +): AppleToolProvider { + const ok = { stdout: '', stderr: '', exitCode: 0 }; + let simulatorBooted = false; + return { + whichCommand: async () => true, + runCommand: async (cmd, args) => { + throw new Error(`Unscripted Apple install ledger host call: ${cmd} ${args.join(' ')}`); + }, + plist: { + readJson: async () => ({ + CFBundleIdentifier: 'com.example.demo', + CFBundleName: 'Demo', + }), + }, + simctl: { + run: async (args, options) => { + recordLedgerCall(ledger, 'simctl', args, options?.timeoutMs, clock); + if (args.includes('list')) { + return { + stdout: JSON.stringify({ + devices: { + 'iOS 18.5': [{ udid: deviceId, state: simulatorBooted ? 'Booted' : 'Shutdown' }], + }, + }), + stderr: '', + exitCode: 0, + }; + } + if (args.includes('boot')) { + simulatorBooted = true; + return ok; + } + assert.ok( + args.includes('bootstatus') || args.includes('install') || args.includes('uninstall'), + args.join(' '), + ); + return ok; + }, + }, + devicectl: { + run: async (args, options) => { + recordLedgerCall(ledger, 'devicectl', args, options?.timeoutMs, clock); + if (args.includes('details')) { + writeCoreDeviceDetailsJson(args); + return ok; + } + assert.equal(route, 'iOS physical device'); + assert.ok(args.includes('install') || args.includes('uninstall'), args.join(' ')); + return ok; + }, + }, + }; +} + +function writeCoreDeviceDetailsJson(args: readonly string[]): void { + const jsonOutputIndex = args.indexOf('--json-output'); + const jsonPath = jsonOutputIndex >= 0 ? args[jsonOutputIndex + 1] : undefined; + assert.ok(jsonPath, `devicectl details call did not include --json-output: ${args.join(' ')}`); + fs.writeFileSync( + jsonPath, + JSON.stringify({ + result: { + connectionProperties: { tunnelState: 'connected', tunnelIPAddress: '127.0.0.1' }, + deviceProperties: { + bootState: 'booted', + developerModeStatus: 'enabled', + ddiServicesAvailable: true, + }, + }, + }), + ); +} + +async function createAndroidScenario( + route: 'Android APK' | 'Android AAB fallback', + clock: InstallLedgerClock, +): Promise { + const ledger: InstallLedgerCall[] = []; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-install-ledger-android-')); + const appPath = path.join(tempRoot, route === 'Android APK' ? 'Demo.apk' : 'Demo.aab'); + const bundletoolPath = path.join(tempRoot, 'bundletool'); + const bundletoolLedgerPath = path.join(tempRoot, 'bundletool-ledger.jsonl'); + fs.writeFileSync(appPath, 'placeholder apk'); + writeBundletoolLedgerScript(bundletoolPath); + fs.chmodSync(bundletoolPath, 0o755); + let installed = false; + let syncedBundletoolCallCount = 0; + const syncBundletoolLedger = () => { + if (!fs.existsSync(bundletoolLedgerPath)) return; + const lines = fs.readFileSync(bundletoolLedgerPath, 'utf8').split('\n').filter(Boolean); + for (; syncedBundletoolCallCount < lines.length; syncedBundletoolCallCount += 1) { + const line = lines[syncedBundletoolCallCount]; + assert.ok(line !== undefined); + const parsedArgs = JSON.parse(line) as unknown; + assert.ok(Array.isArray(parsedArgs) && parsedArgs.every((arg) => typeof arg === 'string')); + const bundletoolArgs = parsedArgs as string[]; + recordLedgerCall(ledger, 'bundletool', bundletoolArgs, ANDROID_BUNDLETOOL_TIMEOUT_MS, clock); + if (bundletoolArgs.includes('install-apks')) installed = true; + } + }; + const provider: AndroidAdbProvider = { + exec: async (args, options) => { + syncBundletoolLedger(); + recordLedgerCall(ledger, 'adb', args, options?.timeoutMs, clock); + const key = args.join(' '); + if (key === 'shell pm list packages') { + return { + stdout: installed ? 'package:com.example.demo\n' : '', + stderr: '', + exitCode: 0, + }; + } + if (key === 'uninstall com.example.demo') { + installed = false; + return { stdout: 'Success\n', stderr: '', exitCode: 0 }; + } + throw new Error(`Unscripted Android install ledger adb call: ${key}`); + }, + install: async (apkPath, options) => { + recordLedgerCall(ledger, 'adb-install', [apkPath], options?.timeoutMs, clock); + installed = true; + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const commandOverride = createInstallLedgerHostCommandOverride(ledger, clock, { + onBundletoolInstallApks: () => { + installed = true; + }, + }); + const daemon = await withCommandExecutorOverride( + commandOverride, + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + env: { + AGENT_DEVICE_BUNDLETOOL_LEDGER: bundletoolLedgerPath, + PATH: `${tempRoot}${path.delimiter}${process.env.PATH ?? ''}`, + }, + }), + ); + return { + appPath, + clock, + daemon, + ledger, + selection: { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id }, + close: async () => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + await daemon.close(); + }, + }; +} + +function writeBundletoolLedgerScript(bundletoolPath: string): void { + fs.writeFileSync( + bundletoolPath, + [ + '#!/usr/bin/env node', + "const fs = require('node:fs');", + 'const ledger = process.env.AGENT_DEVICE_BUNDLETOOL_LEDGER;', + String.raw`if (ledger) fs.appendFileSync(ledger, JSON.stringify(process.argv.slice(2)) + '\n');`, + ].join('\n'), + ); +} + +function isAndroidRoute(route: InstallRoute): route is 'Android APK' | 'Android AAB fallback' { + return route.startsWith('Android'); +} + +function createInstallLedgerHostCommandOverride( + ledger: InstallLedgerCall[], + clock: InstallLedgerClock, + hooks: Readonly<{ onBundletoolInstallApks?: () => void }> = {}, +): CommandExecutorOverride { + return (command, args, options) => { + if (command === 'open') { + recordLedgerCall(ledger, 'open', args, options.timeoutMs, clock); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + if (command === 'bundletool' || command === 'java') { + recordLedgerCall(ledger, 'bundletool', args, options.timeoutMs, clock); + if (args.includes('install-apks')) hooks.onBundletoolInstallApks?.(); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + return undefined; + }; +} + +function recordLedgerCall( + ledger: InstallLedgerCall[], + tool: InstallLedgerCall['tool'], + args: readonly string[], + timeoutMs: number | undefined, + clock: InstallLedgerClock, +): void { + assert.ok( + Number.isFinite(timeoutMs) && timeoutMs! > 0, + `install ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, + ); + ledger.push({ tool, args: [...args], timeoutMs: timeoutMs! }); + clock.advanceByLedgerTimeout(timeoutMs!); +} + +function assertLedgerFitsEnvelope( + route: string, + command: InstallCommand, + ledger: readonly InstallLedgerCall[], +): void { + const virtualElapsedMs = ledger.reduce( + (total, call) => total + Math.max(0, call.timeoutMs - 1), + 0, + ); + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy(command), { + positionals: [], + flags: {}, + }); + assert.ok(envelopeMs !== undefined, `${command} must declare a bounded envelope`); + assert.ok( + virtualElapsedMs + REQUIRED_DAEMON_RESULT_MARGIN_MS <= envelopeMs, + `${route} ${command} ledger worst case (${virtualElapsedMs}ms) + ` + + `${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms margin exceeds the ${envelopeMs}ms envelope.\n` + + `Ledger:\n${JSON.stringify(ledger, null, 2)}`, + ); +} + +function assertInstallLedgerPhaseCoverage( + route: InstallRoute, + command: InstallCommand, + ledger: readonly InstallLedgerCall[], +): void { + assert.ok(ledger.length > 0, `${route} ${command} ledger recorded no calls`); + assert.ok( + ledger.some( + (call) => call.args.some((arg) => arg.includes('install')) || call.tool === 'adb-install', + ), + `${route} ${command} ledger is missing the install phase.\nLedger:\n${JSON.stringify( + ledger, + null, + 2, + )}`, + ); + if (command === 'reinstall') { + assert.ok( + ledger.some((call) => call.args.includes('uninstall')), + `${route} reinstall ledger is missing the uninstall phase`, + ); + } + if (route === 'iOS physical device') { + assert.ok( + ledger.some((call) => call.args.includes('details')), + `${route} ${command} ledger is missing the readiness phase`, + ); + } + if (route === 'iOS simulator') { + assert.ok( + ledger.some((call) => call.args.includes('list')), + `${route} ${command} ledger is missing the boot-state probe phase`, + ); + assert.ok( + ledger.some((call) => call.args.includes('boot')), + `${route} ${command} ledger is missing the simulator boot phase`, + ); + assert.ok( + ledger.some((call) => call.args.includes('bootstatus')), + `${route} ${command} ledger is missing the simulator bootstatus phase`, + ); + assert.ok( + ledger.some((call) => call.tool === 'open' && call.args.includes('Simulator')), + `${route} ${command} ledger is missing the Simulator.app reveal phase`, + ); + } + if (isAndroidRoute(route) && (command === 'install' || command === 'install_source')) { + assert.ok( + ledger.some((call) => call.args.join(' ') === 'shell pm list packages'), + `${route} ${command} ledger is missing the package-inventory phase`, + ); + } + if (route === 'Android AAB fallback') { + assert.ok( + ledger.some((call) => call.args.includes('build-apks')), + `${route} ${command} ledger is missing the bundle build phase`, + ); + assert.ok( + ledger.some((call) => call.args.includes('install-apks')), + `${route} ${command} ledger is missing the bundle install phase`, + ); + } +} From 4799732a9def86135ad7b35769e7e82f03a8c95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 10:31:58 +0100 Subject: [PATCH 02/18] fix(timeouts): include startup and inspection in install budgets --- .changeset/install-timeout-ledger.md | 5 + .../src/descriptor-timeout-policies.ts | 2 - .../src/descriptors/device-operations.ts | 8 +- .../src/descriptors/session-operations.ts | 2 +- .../command-registry/src/timeout-policy.ts | 4 +- packages/platform-android/src/manifest.ts | 4 + packages/provision-kit/src/install-source.ts | 6 +- .../command-descriptor-timeout-policy.test.ts | 16 +- src/commands/management/install.ts | 8 +- .../command-tools-timeout-note.test.ts | 4 +- .../install-timeout-ledger.test.ts | 379 ++++++++---------- website/docs/docs/commands.md | 2 + 12 files changed, 203 insertions(+), 237 deletions(-) create mode 100644 .changeset/install-timeout-ledger.md diff --git a/.changeset/install-timeout-ledger.md b/.changeset/install-timeout-ledger.md new file mode 100644 index 0000000000..62002fc14a --- /dev/null +++ b/.changeset/install-timeout-ledger.md @@ -0,0 +1,5 @@ +--- +'agent-device': patch +--- + +Allow 480 seconds for `install`, `reinstall`, and `uninstall`, and 600 seconds for `install-from-source`, so device startup and artifact preparation leave time for the daemon to return its result. Local Android and iOS install steps now time out after 120 seconds; Android `.aab` builds and installs each have that limit. Android artifact inspection has a 15-second limit, Android uninstall has a 15-second limit, and iOS uninstall has a 20-second limit. diff --git a/packages/command-registry/src/descriptor-timeout-policies.ts b/packages/command-registry/src/descriptor-timeout-policies.ts index 7eca256bdc..897dc0d815 100644 --- a/packages/command-registry/src/descriptor-timeout-policies.ts +++ b/packages/command-registry/src/descriptor-timeout-policies.ts @@ -17,8 +17,6 @@ export const PRESERVE_DAEMON_TIMEOUT_POLICY: CommandTimeoutPolicy = { onTimeout: 'preserve-daemon', }; -// Installs run long device subprocesses; their envelope stays above the longest -// platform install subprocess timeout (see INSTALL_REQUEST_TIMEOUT_MS). export const INSTALL_TIMEOUT_POLICY: CommandTimeoutPolicy = { ...DEFAULT_TIMEOUT_POLICY, envelopeMs: INSTALL_REQUEST_TIMEOUT_MS, diff --git a/packages/command-registry/src/descriptors/device-operations.ts b/packages/command-registry/src/descriptors/device-operations.ts index 936358ea7a..ddd0241d1c 100644 --- a/packages/command-registry/src/descriptors/device-operations.ts +++ b/packages/command-registry/src/descriptors/device-operations.ts @@ -6,7 +6,11 @@ import { settingsRefFrameEffect, NO_PLATFORM_EXECUTION, } from '../descriptor-request-policy.ts'; -import { DEFAULT_TIMEOUT_POLICY, FOLD_TIMEOUT_POLICY } from '../timeout-policy.ts'; +import { + DEFAULT_TIMEOUT_POLICY, + FOLD_TIMEOUT_POLICY, + INSTALL_SOURCE_REQUEST_TIMEOUT_MS, +} from '../timeout-policy.ts'; import { FILL_INTERACTION_RESPONSE_DATA_TRANSFORM, GENERIC_MUTATING_COMMAND_TRAITS, @@ -574,7 +578,7 @@ export const DEVICE_OPERATION_COMMAND_DESCRIPTORS = { recordsSessionAction: true, recordingEffect: 'mutates-app', platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, envelopeMs: INSTALL_SOURCE_REQUEST_TIMEOUT_MS }, batchable: true, compositeResource: 'none', }, diff --git a/packages/command-registry/src/descriptors/session-operations.ts b/packages/command-registry/src/descriptors/session-operations.ts index d72780f1c2..2bf9053dc6 100644 --- a/packages/command-registry/src/descriptors/session-operations.ts +++ b/packages/command-registry/src/descriptors/session-operations.ts @@ -8,6 +8,7 @@ import { import { COMPOSITE_TIMEOUT_POLICIES, DEFAULT_TIMEOUT_POLICY, + INSTALL_SOURCE_REQUEST_TIMEOUT_MS, PREPARE_REQUEST_TIMEOUT_MS, } from '../timeout-policy.ts'; import { @@ -17,7 +18,6 @@ import { ownerFilesEnabled, type RawCommandDescriptor, } from '../descriptor-traits.ts'; -import { INSTALL_SOURCE_REQUEST_TIMEOUT_MS } from '../timeout-policy.ts'; import { INSTALL_TIMEOUT_POLICY } from '../descriptor-timeout-policies.ts'; import { deployAppUse, diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 3b10bb03da..e1aa395776 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -8,10 +8,10 @@ const DAEMON_REQUEST_TIMEOUT_MS = 90_000; // Covers the install/reinstall/uninstall route's worst-case ledger plus the daemon-result margin. // Proven by the ledger test: `test/integration/provider-scenarios/install-timeout-ledger.test.ts`. -export const INSTALL_REQUEST_TIMEOUT_MS = 330_000; +export const INSTALL_REQUEST_TIMEOUT_MS = 480_000; // Adds room for install-from-source URL materialization before the same platform install ledgers. -export const INSTALL_SOURCE_REQUEST_TIMEOUT_MS = 420_000; +export const INSTALL_SOURCE_REQUEST_TIMEOUT_MS = 600_000; // Margin over a daemon-side budget so the daemon's own timeout result (with // diagnostics) wins the race against the client envelope. Never shrinks the diff --git a/packages/platform-android/src/manifest.ts b/packages/platform-android/src/manifest.ts index f8b5cd8cd7..a07e21468b 100644 --- a/packages/platform-android/src/manifest.ts +++ b/packages/platform-android/src/manifest.ts @@ -4,6 +4,8 @@ import { runCmd } from '@agent-device/host-kit/command'; import { resolveAndroidSdkRoots } from './sdk.ts'; import { requireAndroidAdbHost } from './adb-host.ts'; +const ARCHIVE_INSPECTION_TIMEOUT_MS = 15_000; + const RES_XML_TYPE = 0x0003; const RES_STRING_POOL_TYPE = 0x0001; const RES_XML_START_ELEMENT_TYPE = 0x0102; @@ -46,6 +48,7 @@ async function readZipEntry( const result = await runCmd('unzip', ['-p', archivePath, entry], { allowFailure: true, binaryStdout: true, + timeoutMs: ARCHIVE_INSPECTION_TIMEOUT_MS, signal, }); if (result.exitCode !== 0 || !result.stdoutBuffer || result.stdoutBuffer.length === 0) { @@ -327,6 +330,7 @@ async function resolveAndroidArchivePackageNameWithAapt( if (!aaptPath) return undefined; const result = await runCmd(aaptPath, ['dump', 'badging', archivePath], { allowFailure: true, + timeoutMs: ARCHIVE_INSPECTION_TIMEOUT_MS, signal, }); if (result.exitCode !== 0) return undefined; diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index e4d058f9d7..136e68e94a 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -45,7 +45,7 @@ const INTERNAL_ARCHIVE_EXTENSIONS = ['.zip', '.tar', '.tar.gz', '.tgz'] as const * @public Archive extensions accepted by install-source resolution. */ export const ARCHIVE_EXTENSIONS = Object.freeze([...INTERNAL_ARCHIVE_EXTENSIONS] as const); -export const INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; +const DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; export async function materializeInstallablePath( options: MaterializeInstallableOptions, @@ -124,7 +124,7 @@ async function downloadToTempFile( if (requestSignal?.aborted) { throw createRequestCanceledError(); } - const timeoutSignal = AbortSignal.timeout(INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout(DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; try { return await downloadInstallSource({ @@ -139,7 +139,7 @@ async function downloadToTempFile( error, requestSignal, timeoutSignal, - INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS, + DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS, ); } } diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index a5ca799fee..39e3600fdd 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -145,11 +145,11 @@ test('request envelopes deviating from the default are bounded, reviewed sets', const EXPECTED_ENVELOPES: Record = { // prepare: daemon-side runner budget (PREPARE_STARTUP_BUDGET_MS) plus the daemon-result margin. prepare: 270_000, - install: 330_000, - reinstall: 330_000, - uninstall: 330_000, - 'install-from-source': 420_000, - install_source: 420_000, + install: 480_000, + reinstall: 480_000, + uninstall: 480_000, + 'install-from-source': 600_000, + install_source: 600_000, // longpress: proven by the worst-case ledger test in // test/integration/provider-scenarios/android-longpress-ledger.test.ts. longpress: 320_000, @@ -312,15 +312,15 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('install'), { ...base }), - 330_000, + 480_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('reinstall'), { ...base }), - 330_000, + 480_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('install_source'), { ...base }), - 420_000, + 600_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 3a6b517cd5..b795f7f76a 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -29,7 +29,7 @@ import { managementCliOutputFormatters } from './output.ts'; const installCommandMetadata = defineFieldCommandMetadata( 'install', - 'Install an app binary from a local path. Provide an app identifier with the path when the target needs explicit app selection; use reinstall to replace an already installed app.', + 'Install an app binary from a local path. Provide an app identifier with the path when the target needs explicit app selection; use reinstall to replace an already installed app. Local Android and iOS install steps time out after 120s; the command allows 480s including device startup.', { app: stringField('Optional app identifier hint.'), appPath: requiredField(stringField('Path to app binary.')), @@ -39,7 +39,7 @@ const installCommandMetadata = defineFieldCommandMetadata( const reinstallCommandMetadata = defineFieldCommandMetadata( 'reinstall', - 'Replace an installed app with a binary from a local path. Use this when preserving the same app identity while installing a new build on the selected device.', + 'Replace an installed app with a binary from a local path. Use this when preserving the same app identity while installing a new build on the selected device. Local Android and iOS install steps time out after 120s; the command allows 480s including device startup and uninstall.', { app: requiredField(stringField()), appPath: requiredField(stringField('Path to app binary.')), @@ -49,7 +49,7 @@ const reinstallCommandMetadata = defineFieldCommandMetadata( const uninstallCommandMetadata = defineFieldCommandMetadata( 'uninstall', - 'Remove an installed app from the selected device. A package name or bundle id that is not installed succeeds with wasInstalled: false.', + 'Remove an installed app from the selected device. A package name or bundle id that is not installed succeeds with wasInstalled: false. The command allows 480s including device startup.', { app: requiredField(stringField('Package name, bundle id, or installed app name.')), ...leaseDeviceSpecFields(), @@ -58,7 +58,7 @@ const uninstallCommandMetadata = defineFieldCommandMetadata( const installFromSourceCommandMetadata = defineFieldCommandMetadata( 'install-from-source', - 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon.', + 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon. The command allows 600s, including up to 120s for the download; local Android and iOS install steps time out after 120s.', { source: requiredField( jsonSchemaField(looseObjectSchema('Install source object.')), diff --git a/src/mcp/__tests__/command-tools-timeout-note.test.ts b/src/mcp/__tests__/command-tools-timeout-note.test.ts index eb71f2e688..f60b060400 100644 --- a/src/mcp/__tests__/command-tools-timeout-note.test.ts +++ b/src/mcp/__tests__/command-tools-timeout-note.test.ts @@ -37,6 +37,6 @@ test('every MCP tool description declares its timeout envelope', () => { test('the declared envelopes carry the known tier values', () => { const descriptions = new Map(listCommandTools().map((tool) => [tool.name, tool.description])); assert.match(descriptions.get('devices') ?? '', / Times out after 90s\.$/); - assert.match(descriptions.get('install') ?? '', / Times out after 330s\.$/); - assert.match(descriptions.get('install-from-source') ?? '', / Times out after 420s\.$/); + assert.match(descriptions.get('install') ?? '', / Times out after 480s\.$/); + assert.match(descriptions.get('install-from-source') ?? '', / Times out after 600s\.$/); }); diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 677760b705..3873b7982f 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -1,17 +1,20 @@ +import * as inventoryContext from '@agent-device/device-selection/device-inventory-context'; import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import * as hostCommands from '@agent-device/host-kit/command'; import { withCommandExecutorOverride, type CommandExecutorOverride, } from '@agent-device/host-kit/command'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import * as timers from 'node:timers/promises'; +import { pack } from 'tar-stream'; import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; -import { INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS } from '@agent-device/provision-kit/install-source'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; import assert from 'node:assert/strict'; import dns from 'node:dns/promises'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; import { test, vi } from 'vitest'; @@ -27,10 +30,12 @@ import { type ProviderScenarioHarness, } from './harness.ts'; +vi.mock('node:timers/promises', async (original) => ({ ...(await original()) })); + type InstallCommand = 'install' | 'reinstall' | 'install_source'; type InstallLedgerCall = Readonly<{ - tool: 'adb' | 'adb-install' | 'bundletool' | 'download' | 'open' | 'simctl' | 'devicectl'; + tool: string; args: readonly string[]; timeoutMs: number; }>; @@ -41,25 +46,36 @@ type InstallRoute = | 'Android APK' | 'Android AAB fallback'; -type InstallLedgerClock = Readonly<{ - advanceByLedgerTimeout(timeoutMs: number): void; -}>; +class InstallLedger { + readonly calls: InstallLedgerCall[] = []; + now = 1_700_000_000_000; + + record( + tool: string, + args: readonly string[], + timeoutMs: number | undefined, + elapsedMs?: number, + ): void { + assert.ok( + typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0, + `Unbounded install phase: ${tool} ${args.join(' ')}`, + ); + this.calls.push({ tool, args: [...args], timeoutMs }); + this.now += Math.max(0, elapsedMs ?? timeoutMs - 1); + } +} type InstallLedgerScenario = ClosableProviderScenarioResource & Readonly<{ appPath: string; - clock: InstallLedgerClock; daemon: ProviderScenarioHarness; - ledger: InstallLedgerCall[]; + ledger: InstallLedger; selection: Readonly< - { platform: 'android'; serial: string } | { platform: 'ios'; udid: string } + { platform: 'android'; device: string } | { platform: 'ios'; udid: string } >; }>; const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; -// The native deployment unit asserts this is the timeout passed to bundletool; the fake -// executable can only report argv back across the daemon HTTP boundary. -const ANDROID_BUNDLETOOL_TIMEOUT_MS = 120_000; let nextAppleScenarioId = 0; for (const route of [ @@ -68,54 +84,43 @@ for (const route of [ 'Android APK', 'Android AAB fallback', ] as const) { - test(`install-family request envelope covers the ${route} worst-case ledger`, async () => { - for (const command of ['install', 'reinstall', 'install_source'] as const) { + for (const command of ['install', 'reinstall', 'install_source', 'install_source URL'] as const) { + test(`${route} ${command} leaves 30s for the daemon result`, async () => { await withInstallLedgerScenario(route, async (scenario) => { - await runInstallCommand(scenario, command); - assertInstallLedgerPhaseCoverage(route, command, scenario.ledger); - assertLedgerFitsEnvelope(route, command, scenario.ledger); + if (command === 'install_source URL') await runInstallSourceFromUrlCommand(scenario); + else await runInstallCommand(scenario, command); + const daemonCommand = command === 'install_source URL' ? 'install_source' : command; + assertInstallLedgerPhaseCoverage(route, daemonCommand, scenario.ledger.calls); + if (command === 'install_source URL') { + assert.ok(scenario.ledger.calls.some((call) => call.tool === 'download')); + } + assertLedgerFitsEnvelope(route, daemonCommand, scenario.ledger.calls); }); - } - }, 20_000); + }); + } } -test('install_source request envelope covers URL download before the Android AAB fallback ledger', async () => { - await withInstallLedgerScenario('Android AAB fallback', async (scenario) => { - await runInstallSourceFromUrlCommand(scenario); - assertInstallLedgerPhaseCoverage('Android AAB fallback', 'install_source', scenario.ledger); - assert.ok( - scenario.ledger.some((call) => call.tool === 'download'), - `install_source URL ledger is missing the download phase.\nLedger:\n${JSON.stringify( - scenario.ledger, - null, - 2, - )}`, - ); - assertLedgerFitsEnvelope('Android AAB fallback URL source', 'install_source', scenario.ledger); - }); -}, 20_000); - async function withInstallLedgerScenario( route: InstallRoute, run: (scenario: InstallLedgerScenario) => Promise, ): Promise { - let virtualNowMs = 1_700_000_000_000; - const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => virtualNowMs); - const clock: InstallLedgerClock = { - advanceByLedgerTimeout: (timeoutMs) => { - virtualNowMs += Math.max(0, timeoutMs - 1); - }, - }; - const scenario = isAndroidRoute(route) - ? await createAndroidScenario(route, clock) - : await createIosScenario(route, clock); + const ledger = new InstallLedger(); + vi.spyOn(Date, 'now').mockImplementation(() => ledger.now); + vi.spyOn(timers, 'setTimeout').mockImplementation(async (ms = 1, value) => { + ledger.record('sleep', [], ms, ms); + return value; + }); + let scenario: InstallLedgerScenario | undefined; try { + scenario = isAndroidRoute(route) + ? await createAndroidScenario(route, ledger) + : await createIosScenario(route, ledger); return await run(scenario); } finally { try { - await scenario.close(); + await scenario?.close(); } finally { - dateNow.mockRestore(); + vi.restoreAllMocks(); } } } @@ -148,6 +153,20 @@ async function runInstallCommand( } async function runInstallSourceFromUrlCommand(scenario: InstallLedgerScenario): Promise { + const timeouts = new WeakMap(); + const timeout = AbortSignal.timeout.bind(AbortSignal); + const any = AbortSignal.any.bind(AbortSignal); + const timeoutMock = vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => { + const signal = timeout(ms); + timeouts.set(signal, ms); + return signal; + }); + const anyMock = vi.spyOn(AbortSignal, 'any').mockImplementation((signals) => { + const signal = any(signals); + const ms = signals.map((part) => timeouts.get(part)).find((value) => value !== undefined); + if (ms !== undefined) timeouts.set(signal, ms); + return signal; + }); const lookupMock = vi .spyOn(dns, 'lookup') .mockImplementation( @@ -159,36 +178,34 @@ async function runInstallSourceFromUrlCommand(scenario: InstallLedgerScenario): const requestMock = vi .spyOn(networkTransport, 'requestApprovedUrl') .mockImplementation(async (request): Promise => { - recordLedgerCall( - scenario.ledger, - 'download', - [request.url.toString()], - INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS, - scenario.clock, - ); + scenario.ledger.record('download', [request.url.toString()], timeouts.get(request.signal)); return { statusCode: 200, headers: {}, - body: Readable.from([fs.readFileSync(scenario.appPath)]), + body: downloadBody(scenario.appPath), close: async () => {}, }; }); try { await scenario.daemon.client().apps.installFromSource({ - source: { kind: 'url', url: 'https://artifacts.example.com/Demo.aab' }, + source: { + kind: 'url', + url: `https://artifacts.example.com/${path.basename(scenario.appPath)}${scenario.appPath.endsWith('.app') ? '.tar' : ''}`, + }, ...scenario.selection, }); } finally { requestMock.mockRestore(); lookupMock.mockRestore(); + anyMock.mockRestore(); + timeoutMock.mockRestore(); } } async function createIosScenario( route: 'iOS physical device' | 'iOS simulator', - clock: InstallLedgerClock, + ledger: InstallLedger, ): Promise { - const ledger: InstallLedgerCall[] = []; const scenarioId = ++nextAppleScenarioId; const device = route === 'iOS physical device' @@ -203,19 +220,17 @@ async function createIosScenario( booted: false, }; const { tempRoot, appPath } = createDemoIosApp('agent-device-install-ledger-ios-'); - const commandOverride = createInstallLedgerHostCommandOverride(ledger, clock); + const commandOverride = createInstallLedgerHostCommandOverride(ledger); const daemon = await withCommandExecutorOverride( commandOverride, async () => await createProviderScenarioHarness({ deviceInventoryProvider: async () => [device], - appleToolProvider: () => - createInstallLedgerAppleToolProvider(route, device.id, ledger, clock), + appleToolProvider: () => createInstallLedgerAppleToolProvider(route, device.id, ledger), }), ); return { appPath, - clock, daemon, ledger, selection: { platform: 'ios', udid: device.id }, @@ -229,8 +244,7 @@ async function createIosScenario( function createInstallLedgerAppleToolProvider( route: 'iOS physical device' | 'iOS simulator', deviceId: string, - ledger: InstallLedgerCall[], - clock: InstallLedgerClock, + ledger: InstallLedger, ): AppleToolProvider { const ok = { stdout: '', stderr: '', exitCode: 0 }; let simulatorBooted = false; @@ -247,7 +261,7 @@ function createInstallLedgerAppleToolProvider( }, simctl: { run: async (args, options) => { - recordLedgerCall(ledger, 'simctl', args, options?.timeoutMs, clock); + ledger.record('simctl', args, options?.timeoutMs); if (args.includes('list')) { return { stdout: JSON.stringify({ @@ -272,7 +286,7 @@ function createInstallLedgerAppleToolProvider( }, devicectl: { run: async (args, options) => { - recordLedgerCall(ledger, 'devicectl', args, options?.timeoutMs, clock); + ledger.record('devicectl', args, options?.timeoutMs); if (args.includes('details')) { writeCoreDeviceDetailsJson(args); return ok; @@ -306,135 +320,109 @@ function writeCoreDeviceDetailsJson(args: readonly string[]): void { async function createAndroidScenario( route: 'Android APK' | 'Android AAB fallback', - clock: InstallLedgerClock, + ledger: InstallLedger, ): Promise { - const ledger: InstallLedgerCall[] = []; - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-install-ledger-android-')); + const tempRoot = mkdtempForTestSync('agent-device-install-ledger-android-'); const appPath = path.join(tempRoot, route === 'Android APK' ? 'Demo.apk' : 'Demo.aab'); - const bundletoolPath = path.join(tempRoot, 'bundletool'); - const bundletoolLedgerPath = path.join(tempRoot, 'bundletool-ledger.jsonl'); - fs.writeFileSync(appPath, 'placeholder apk'); - writeBundletoolLedgerScript(bundletoolPath); - fs.chmodSync(bundletoolPath, 0o755); + fs.writeFileSync(appPath, 'placeholder artifact'); + for (const tool of ['adb', 'emulator', 'bundletool']) { + fs.writeFileSync(path.join(tempRoot, tool), '', { mode: 0o755 }); + } + const aaptPath = path.join(tempRoot, 'build-tools', '99.0.0', 'aapt'); + fs.mkdirSync(path.dirname(aaptPath), { recursive: true }); + fs.writeFileSync(aaptPath, '', { mode: 0o755 }); let installed = false; - let syncedBundletoolCallCount = 0; - const syncBundletoolLedger = () => { - if (!fs.existsSync(bundletoolLedgerPath)) return; - const lines = fs.readFileSync(bundletoolLedgerPath, 'utf8').split('\n').filter(Boolean); - for (; syncedBundletoolCallCount < lines.length; syncedBundletoolCallCount += 1) { - const line = lines[syncedBundletoolCallCount]; - assert.ok(line !== undefined); - const parsedArgs = JSON.parse(line) as unknown; - assert.ok(Array.isArray(parsedArgs) && parsedArgs.every((arg) => typeof arg === 'string')); - const bundletoolArgs = parsedArgs as string[]; - recordLedgerCall(ledger, 'bundletool', bundletoolArgs, ANDROID_BUNDLETOOL_TIMEOUT_MS, clock); - if (bundletoolArgs.includes('install-apks')) installed = true; - } - }; + let launched = false; + let bootProbeCapMs: number | undefined; + const device = { ...PROVIDER_SCENARIO_ANDROID, id: 'Pixel_8', booted: false }; + vi.spyOn(hostCommands, 'runCmdDetached').mockImplementation((command) => { + assert.equal(command, 'emulator'); + launched = true; + return 4242; + }); const provider: AndroidAdbProvider = { exec: async (args, options) => { - syncBundletoolLedger(); - recordLedgerCall(ledger, 'adb', args, options?.timeoutMs, clock); - const key = args.join(' '); - if (key === 'shell pm list packages') { - return { - stdout: installed ? 'package:com.example.demo\n' : '', - stderr: '', - exitCode: 0, - }; - } - if (key === 'uninstall com.example.demo') { - installed = false; - return { stdout: 'Success\n', stderr: '', exitCode: 0 }; + ledger.record('adb', args, options?.timeoutMs); + if (args.join(' ') === 'shell pm list packages') { + return { stdout: installed ? 'package:com.example.demo\n' : '', stderr: '', exitCode: 0 }; } - throw new Error(`Unscripted Android install ledger adb call: ${key}`); + assert.deepEqual(args, ['uninstall', 'com.example.demo']); + installed = false; + return { stdout: 'Success\n', stderr: '', exitCode: 0 }; }, install: async (apkPath, options) => { - recordLedgerCall(ledger, 'adb-install', [apkPath], options?.timeoutMs, clock); + ledger.record('adb-install', [apkPath], options?.timeoutMs); installed = true; return { stdout: '', stderr: '', exitCode: 0 }; }, }; - const commandOverride = createInstallLedgerHostCommandOverride(ledger, clock, { - onBundletoolInstallApks: () => { - installed = true; - }, + const commands: CommandExecutorOverride = async (command, args, options) => { + if (command === 'adb' && args.some((arg) => arg.includes('sys.boot_completed'))) { + assert.ok(options.timeoutMs); + bootProbeCapMs ??= options.timeoutMs; + const ready = options.timeoutMs < bootProbeCapMs; + // Leave room for the poll sleep; complete only when the real boot deadline narrows a probe. + ledger.record( + command, + args, + options.timeoutMs, + ready ? undefined : options.timeoutMs - 1_001, + ); + return { stdout: ready ? '1' : '0', stderr: '', exitCode: 0 }; + } + ledger.record(command, args, options.timeoutMs); + if (command === '/usr/bin/lsappinfo') return { stdout: '', stderr: '', exitCode: 0 }; + if (command === '/usr/sbin/sysctl') return { stdout: '1', stderr: '', exitCode: 0 }; + if (command === 'bundletool') { + if (args.includes('install-apks')) installed = true; + return { stdout: '', stderr: '', exitCode: 0 }; + } + assert.ok(command === 'unzip' || path.basename(command) === 'aapt', command); + return { stdout: '', stderr: '', exitCode: 1 }; + }; + const inventory = async () => [launched ? { ...device, id: 'emulator-5554' } : device]; + vi.spyOn(inventoryContext, 'listLocalDeviceInventory').mockImplementation(inventory); + vi.spyOn(hostCommands, 'runCmd').mockImplementation(async (command, args, options = {}) => { + const result = commands(command, args, options); + assert.ok(result, `Unscripted host command: ${command}`); + return await result; + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => provider, + deviceInventoryProvider: inventory, + env: { PATH: tempRoot, ANDROID_HOME: tempRoot, ANDROID_SDK_ROOT: tempRoot }, }); - const daemon = await withCommandExecutorOverride( - commandOverride, - async () => - await createProviderScenarioHarness({ - androidAdbProvider: () => provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - env: { - AGENT_DEVICE_BUNDLETOOL_LEDGER: bundletoolLedgerPath, - PATH: `${tempRoot}${path.delimiter}${process.env.PATH ?? ''}`, - }, - }), - ); return { appPath, - clock, daemon, ledger, - selection: { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id }, + selection: { platform: 'android', device: device.name }, close: async () => { - fs.rmSync(tempRoot, { recursive: true, force: true }); await daemon.close(); }, }; } -function writeBundletoolLedgerScript(bundletoolPath: string): void { - fs.writeFileSync( - bundletoolPath, - [ - '#!/usr/bin/env node', - "const fs = require('node:fs');", - 'const ledger = process.env.AGENT_DEVICE_BUNDLETOOL_LEDGER;', - String.raw`if (ledger) fs.appendFileSync(ledger, JSON.stringify(process.argv.slice(2)) + '\n');`, - ].join('\n'), - ); +function downloadBody(appPath: string): Readable { + if (!appPath.endsWith('.app')) return Readable.from([fs.readFileSync(appPath)]); + const archive = pack(); + archive.entry({ name: 'Demo.app/Info.plist' }, fs.readFileSync(path.join(appPath, 'Info.plist'))); + archive.finalize(); + return archive; } function isAndroidRoute(route: InstallRoute): route is 'Android APK' | 'Android AAB fallback' { return route.startsWith('Android'); } -function createInstallLedgerHostCommandOverride( - ledger: InstallLedgerCall[], - clock: InstallLedgerClock, - hooks: Readonly<{ onBundletoolInstallApks?: () => void }> = {}, -): CommandExecutorOverride { +function createInstallLedgerHostCommandOverride(ledger: InstallLedger): CommandExecutorOverride { return (command, args, options) => { - if (command === 'open') { - recordLedgerCall(ledger, 'open', args, options.timeoutMs, clock); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - if (command === 'bundletool' || command === 'java') { - recordLedgerCall(ledger, 'bundletool', args, options.timeoutMs, clock); - if (args.includes('install-apks')) hooks.onBundletoolInstallApks?.(); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - } - return undefined; + assert.equal(command, 'open'); + ledger.record(command, args, options.timeoutMs); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); }; } -function recordLedgerCall( - ledger: InstallLedgerCall[], - tool: InstallLedgerCall['tool'], - args: readonly string[], - timeoutMs: number | undefined, - clock: InstallLedgerClock, -): void { - assert.ok( - Number.isFinite(timeoutMs) && timeoutMs! > 0, - `install ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, - ); - ledger.push({ tool, args: [...args], timeoutMs: timeoutMs! }); - clock.advanceByLedgerTimeout(timeoutMs!); -} - function assertLedgerFitsEnvelope( route: string, command: InstallCommand, @@ -462,61 +450,26 @@ function assertInstallLedgerPhaseCoverage( command: InstallCommand, ledger: readonly InstallLedgerCall[], ): void { - assert.ok(ledger.length > 0, `${route} ${command} ledger recorded no calls`); - assert.ok( - ledger.some( - (call) => call.args.some((arg) => arg.includes('install')) || call.tool === 'adb-install', - ), - `${route} ${command} ledger is missing the install phase.\nLedger:\n${JSON.stringify( - ledger, - null, - 2, - )}`, - ); - if (command === 'reinstall') { - assert.ok( - ledger.some((call) => call.args.includes('uninstall')), - `${route} reinstall ledger is missing the uninstall phase`, - ); - } - if (route === 'iOS physical device') { - assert.ok( - ledger.some((call) => call.args.includes('details')), - `${route} ${command} ledger is missing the readiness phase`, - ); - } - if (route === 'iOS simulator') { - assert.ok( - ledger.some((call) => call.args.includes('list')), - `${route} ${command} ledger is missing the boot-state probe phase`, - ); + const expected = isAndroidRoute(route) + ? [ + 'sys.boot_completed', + ...(route === 'Android APK' ? ['adb-install'] : ['build-apks', 'install-apks']), + ] + : route === 'iOS simulator' + ? ['list', 'boot', 'bootstatus', 'open', 'install'] + : ['details', 'install']; + if (command === 'reinstall') expected.push('uninstall'); + if (isAndroidRoute(route)) { assert.ok( - ledger.some((call) => call.args.includes('boot')), - `${route} ${command} ledger is missing the simulator boot phase`, - ); - assert.ok( - ledger.some((call) => call.args.includes('bootstatus')), - `${route} ${command} ledger is missing the simulator bootstatus phase`, - ); - assert.ok( - ledger.some((call) => call.tool === 'open' && call.args.includes('Simulator')), - `${route} ${command} ledger is missing the Simulator.app reveal phase`, + ledger.filter((call) => call.args.join(' ') === 'shell pm list packages').length >= 2, + 'missing before/after package inventories', ); + if (command !== 'reinstall') expected.push('unzip'); } - if (isAndroidRoute(route) && (command === 'install' || command === 'install_source')) { - assert.ok( - ledger.some((call) => call.args.join(' ') === 'shell pm list packages'), - `${route} ${command} ledger is missing the package-inventory phase`, - ); - } - if (route === 'Android AAB fallback') { - assert.ok( - ledger.some((call) => call.args.includes('build-apks')), - `${route} ${command} ledger is missing the bundle build phase`, - ); + for (const phase of expected) { assert.ok( - ledger.some((call) => call.args.includes('install-apks')), - `${route} ${command} ledger is missing the bundle install phase`, + ledger.some((call) => call.tool === phase || call.args.includes(phase)), + `${route} ${command}: missing ${phase} phase. Ledger: ${JSON.stringify(ledger)}`, ); } } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 03d96a0108..b400dc307c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -645,6 +645,7 @@ agent-device install com.example.app ./build/MyApp.app --platform ios - `install ` installs from a binary path without uninstalling first, keeping existing app data where the platform allows. Use it for upgrade flows. - Supports Android devices/emulators, iOS simulators, and CoreDevice-backed iOS physical devices. On xctrace-only devices, install the app with Xcode before opening it by bundle ID. - Formats: Android `.apk`/`.aab`, iOS `.app`/`.ipa`, HarmonyOS `.hap`. For an `.ipa` with several app bundles, `` picks one by bundle id or name. +- On local Android and iOS targets, each install step times out after 120 seconds. `install`, `reinstall`, and `uninstall` allow 480 seconds overall, including device startup. For `.aab`, building and installing the APK set each have a 120-second limit. - `.aab` requires `bundletool` in `PATH`, or `AGENT_DEVICE_BUNDLETOOL_JAR=` with `java` in `PATH`; it installs a universal APK. - Remote daemons upload local app files automatically; prefix the path with `remote:` to use a path on the daemon host as is. A daemon that serves the public remote surface (proxy marker or auth hook) refuses daemon host paths with reason `REMOTE_HOST_INPUT_DENIED`. @@ -675,6 +676,7 @@ agent-device install-from-source --github-actions-artifact thymikee/RNCLI83:6635 ``` - `install-from-source ` installs an artifact the daemon can reach at a URL; use `install` or `reinstall` for local files. Device coverage matches `install`. +- `install-from-source` allows 600 seconds overall, including up to 120 seconds for the download. The local Android and iOS install limits match `install`. - A URL may point to an Android `.apk` or `.aab`, or, from any public host, to an archive containing one `.apk`, `.aab`, `.ipa`, or iOS `.app`. - `--github-actions-artifact ` lets a compatible remote daemon fetch a GitHub Actions artifact with its own credentials. A numeric artifact is treated as an id, anything else as a name. - Repeat `--header ` for authenticated or signed downloads. Sensitive headers are not forwarded to other origins. From 77e68f67a715957ae847a18c3b3151e9b0bea38f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 10:38:58 +0100 Subject: [PATCH 03/18] fix(deployment): retain booted emulator identity and bound plist reads --- .changeset/install-timeout-ledger.md | 4 +- .../handlers/session-app-source-deployment.ts | 13 ++- .../platform-android/src/deployment/native.ts | 8 +- .../src/deployment/runtime.test.ts | 69 ++++++++++------ .../src/deployment/runtime.ts | 4 +- packages/platform-apple/src/core/config.ts | 1 + packages/platform-apple/src/core/plist.ts | 2 + .../platform-apple/src/core/tool-provider.ts | 2 + .../install-timeout-ledger.test.ts | 79 +++++++++---------- 9 files changed, 103 insertions(+), 79 deletions(-) diff --git a/.changeset/install-timeout-ledger.md b/.changeset/install-timeout-ledger.md index 62002fc14a..286deb036d 100644 --- a/.changeset/install-timeout-ledger.md +++ b/.changeset/install-timeout-ledger.md @@ -2,4 +2,6 @@ 'agent-device': patch --- -Allow 480 seconds for `install`, `reinstall`, and `uninstall`, and 600 seconds for `install-from-source`, so device startup and artifact preparation leave time for the daemon to return its result. Local Android and iOS install steps now time out after 120 seconds; Android `.aab` builds and installs each have that limit. Android artifact inspection has a 15-second limit, Android uninstall has a 15-second limit, and iOS uninstall has a 20-second limit. +Allow 480 seconds for `install`, `reinstall`, and `uninstall`, and 600 seconds for `install-from-source`, so device startup and artifact preparation leave time for the daemon to return its result. Local Android and iOS install steps now time out after 120 seconds; Android `.aab` builds and installs each have that limit. Android artifact inspection and Apple plist inspection have a 15-second limit, Android uninstall has a 15-second limit, and iOS uninstall has a 20-second limit. + +Install into the emulator serial discovered after starting a shutdown Android AVD. This also applies to reinstalls and source installs. diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index c6de980bb6..23928be9fd 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -66,7 +66,7 @@ export async function handleInstallFromSourceDeploymentCommand(params: { try { resolvedSource = resolveInstallSource(req); const retention = resolveRetention(req); - const device = await resolveInstallDevice( + let device = await resolveInstallDevice( session, req.flags, sessionStore.host.env, @@ -85,11 +85,18 @@ export async function handleInstallFromSourceDeploymentCommand(params: { ); if (unsupported) return unsupported; - const runtime = await requireRuntimeBinding(params.bindDevice)( + let runtime = await requireRuntimeBinding(params.bindDevice)( device, readyMaterializeAndDeployAppUse, ); - await runtime.operations.ensureReady({}); + const ready = await runtime.operations.ensureReady({}); + if (ready.id !== device.id) { + runtime = await requireRuntimeBinding(params.bindDevice)( + ready, + readyMaterializeAndDeployAppUse, + ); + } + device = ready; materialized = await runtime.operations.materializeAppSource({ source: resolvedSource.source }); retained = await retainIfRequested({ artifact: materialized, diff --git a/packages/platform-android/src/deployment/native.ts b/packages/platform-android/src/deployment/native.ts index 68e9ab8e82..16eeb2fc3b 100644 --- a/packages/platform-android/src/deployment/native.ts +++ b/packages/platform-android/src/deployment/native.ts @@ -39,13 +39,7 @@ export async function uninstallAndroidPackage( packageName: string, signal: AbortSignal, ): Promise { - const result = await runAdb( - host, - device, - ['uninstall', packageName], - signal, - ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, - ); + const result = await runAdb(host, device, ['uninstall', packageName], signal); if (result.exitCode === 0) return; const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (output.includes('unknown package') || output.includes('not installed')) return; diff --git a/packages/platform-android/src/deployment/runtime.test.ts b/packages/platform-android/src/deployment/runtime.test.ts index 7a01d362a1..e36cdf9e4c 100644 --- a/packages/platform-android/src/deployment/runtime.test.ts +++ b/packages/platform-android/src/deployment/runtime.test.ts @@ -103,34 +103,51 @@ test('exposes the complete Android deployment operation set only for admitted ki ).toEqual({}); }); -test('waits for Android readiness before inspecting an install artifact', async () => { - const order: string[] = []; - ensureAndroidReady.mockImplementationOnce(async () => { - order.push('ready'); - return device; - }); - const host = hostFixture({ - prepareArtifact: async () => { - order.push('prepare'); - return { installablePath: '/tmp/app.apk', cleanup: async () => {} }; - }, - }); - native.installAndroidArtifact.mockResolvedValueOnce(undefined); +test.each([false, true])( + 'uses the discovered Android serial for deployment (replaceExisting=%s)', + async (replaceExisting) => { + const order: string[] = []; + ensureAndroidReady.mockImplementationOnce(async () => { + order.push('ready'); + return device; + }); + const host = hostFixture({ + prepareArtifact: async () => { + order.push('prepare'); + return { installablePath: '/tmp/app.apk', cleanup: async () => {} }; + }, + }); + native.installAndroidArtifact.mockResolvedValueOnce(undefined); - await createAndroidAppDeploymentOperations({ - host, - device: { ...device, booted: false }, - signal: new AbortController().signal, - }).deployApp?.({ app: '', appPath: '/tmp/app.apk', replaceExisting: false }); + await createAndroidAppDeploymentOperations({ + host, + device: { ...device, id: 'stopped-avd', booted: false }, + signal: new AbortController().signal, + }).deployApp?.({ app: 'com.example.app', appPath: '/tmp/app.apk', replaceExisting }); - expect(ensureAndroidReady).toHaveBeenCalledWith( - host, - { ...device, booted: false }, - { headless: false }, - expect.any(AbortSignal), - ); - expect(order).toEqual(['ready', 'prepare']); -}); + expect(ensureAndroidReady).toHaveBeenCalledWith( + host, + { ...device, id: 'stopped-avd', booted: false }, + { headless: false }, + expect.any(AbortSignal), + ); + expect(order).toEqual(['ready', 'prepare']); + expect(native.installAndroidArtifact).toHaveBeenCalledWith( + host, + device, + '/tmp/app.apk', + undefined, + expect.any(AbortSignal), + ); + if (replaceExisting) + expect(native.uninstallAndroidPackage).toHaveBeenCalledWith( + host, + device, + 'com.example.app', + expect.any(AbortSignal), + ); + }, +); test('preserves reinstall partial-failure ordering and whole-operation cache invalidation', async () => { const order: string[] = []; diff --git a/packages/platform-android/src/deployment/runtime.ts b/packages/platform-android/src/deployment/runtime.ts index 877dccc1f4..c854f0f7e8 100644 --- a/packages/platform-android/src/deployment/runtime.ts +++ b/packages/platform-android/src/deployment/runtime.ts @@ -105,7 +105,7 @@ async function deployAndroidApp( let artifact: MaterializedAppSource | undefined; try { if (device.booted !== true) { - await ensureAndroidReady(host, device, { headless: false }, signal); + device = await ensureAndroidReady(host, device, { headless: false }, signal); } const packageName = await host.androidDeployment.resolveAppPackage(device, input.app); await uninstallAndroidPackage(host, device, packageName, signal); @@ -127,7 +127,7 @@ async function deployAndroidApp( let artifact: MaterializedAppSource | undefined; try { if (device.booted !== true) { - await ensureAndroidReady(host, device, { headless: false }, signal); + device = await ensureAndroidReady(host, device, { headless: false }, signal); } artifact = await host.androidDeployment.prepareArtifact( { source: { kind: 'path', path: input.appPath } }, diff --git a/packages/platform-apple/src/core/config.ts b/packages/platform-apple/src/core/config.ts index 898dfe8fd2..1bc8f7dbea 100644 --- a/packages/platform-apple/src/core/config.ts +++ b/packages/platform-apple/src/core/config.ts @@ -9,6 +9,7 @@ export const IOS_DEVICECTL_TIMEOUT_MS = 20_000; export const IOS_DEVICECTL_MIN_TIMEOUT_SECONDS = 5; export const IOS_DEVICE_INSTALL_TIMEOUT_MS = 120_000; +export const APPLE_PLIST_TIMEOUT_MS = 15_000; export const IOS_APP_UNINSTALL_TIMEOUT_MS = 20_000; export const IOS_SIMULATOR_SHOW_TIMEOUT_MS = 10_000; diff --git a/packages/platform-apple/src/core/plist.ts b/packages/platform-apple/src/core/plist.ts index a9f94ca9c4..ba81d7b8ff 100644 --- a/packages/platform-apple/src/core/plist.ts +++ b/packages/platform-apple/src/core/plist.ts @@ -1,3 +1,4 @@ +import { APPLE_PLIST_TIMEOUT_MS } from './config.ts'; import { readHostTextFile } from '@agent-device/host-kit/host-file'; import { parseXmlDocumentSync, visitXmlPlistEntries } from '@agent-device/xml'; import { readApplePlistJson, runAppleToolCommand } from './tool-provider.ts'; @@ -24,6 +25,7 @@ export async function readInfoPlistString( ['-extract', key, 'raw', '-o', '-', infoPlistPath], { allowFailure: true, + timeoutMs: APPLE_PLIST_TIMEOUT_MS, signal, }, ); diff --git a/packages/platform-apple/src/core/tool-provider.ts b/packages/platform-apple/src/core/tool-provider.ts index afcc0f9c5d..aca3b72c08 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -1,3 +1,4 @@ +import { APPLE_PLIST_TIMEOUT_MS } from './config.ts'; import { coerceExecResult, runCmd, @@ -192,6 +193,7 @@ async function readPlistJsonWithCommand( try { const result = await runCommand('plutil', ['-convert', 'json', '-o', '-', plistPath], { allowFailure: true, + timeoutMs: APPLE_PLIST_TIMEOUT_MS, signal, }); if (result.exitCode !== 0 || !result.stdout.trim()) { diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 3873b7982f..36b1b2b3a7 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -9,8 +9,10 @@ import { import { mkdtempForTestSync } from '@agent-device/test-kit'; import * as timers from 'node:timers/promises'; import { pack } from 'tar-stream'; -import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; -import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { + createLocalAppleToolProvider, + type AppleToolProvider, +} from '@agent-device/platform-apple/tool-provider'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; import assert from 'node:assert/strict'; import dns from 'node:dns/promises'; @@ -248,16 +250,13 @@ function createInstallLedgerAppleToolProvider( ): AppleToolProvider { const ok = { stdout: '', stderr: '', exitCode: 0 }; let simulatorBooted = false; - return { + return createLocalAppleToolProvider({ whichCommand: async () => true, - runCommand: async (cmd, args) => { - throw new Error(`Unscripted Apple install ledger host call: ${cmd} ${args.join(' ')}`); - }, - plist: { - readJson: async () => ({ - CFBundleIdentifier: 'com.example.demo', - CFBundleName: 'Demo', - }), + runCommand: async (cmd, args, options) => { + assert.equal(cmd, 'plutil'); + ledger.record(cmd, args, options?.timeoutMs); + // Conversion and extraction can both fail before the XML reader resolves the identity. + return { stdout: '', stderr: '', exitCode: 1 }; }, simctl: { run: async (args, options) => { @@ -296,7 +295,7 @@ function createInstallLedgerAppleToolProvider( return ok; }, }, - }; + }); } function writeCoreDeviceDetailsJson(args: readonly string[]): void { @@ -340,23 +339,10 @@ async function createAndroidScenario( launched = true; return 4242; }); - const provider: AndroidAdbProvider = { - exec: async (args, options) => { - ledger.record('adb', args, options?.timeoutMs); - if (args.join(' ') === 'shell pm list packages') { - return { stdout: installed ? 'package:com.example.demo\n' : '', stderr: '', exitCode: 0 }; - } - assert.deepEqual(args, ['uninstall', 'com.example.demo']); - installed = false; - return { stdout: 'Success\n', stderr: '', exitCode: 0 }; - }, - install: async (apkPath, options) => { - ledger.record('adb-install', [apkPath], options?.timeoutMs); - installed = true; - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; - const commands: CommandExecutorOverride = async (command, args, options) => { + vi.spyOn(hostCommands, 'runCmd').mockImplementation(async (command, args, options = {}) => { + if (command === 'adb') { + assert.equal(args[args.indexOf('-s') + 1], 'emulator-5554'); + } if (command === 'adb' && args.some((arg) => arg.includes('sys.boot_completed'))) { assert.ok(options.timeoutMs); bootProbeCapMs ??= options.timeoutMs; @@ -373,22 +359,33 @@ async function createAndroidScenario( ledger.record(command, args, options.timeoutMs); if (command === '/usr/bin/lsappinfo') return { stdout: '', stderr: '', exitCode: 0 }; if (command === '/usr/sbin/sysctl') return { stdout: '1', stderr: '', exitCode: 0 }; + if (command === 'adb') { + if (args.includes('install')) installed = true; + if (args.includes('uninstall')) installed = false; + return { + stdout: + args.includes('shell') && args.join(' ').includes('packages') + ? installed + ? 'package:com.example.demo\n' + : '' + : 'Success', + stderr: '', + exitCode: 0, + }; + } if (command === 'bundletool') { - if (args.includes('install-apks')) installed = true; + if (args.includes('install-apks')) { + assert.equal(args[args.indexOf('--device-id') + 1], 'emulator-5554'); + installed = true; + } return { stdout: '', stderr: '', exitCode: 0 }; } assert.ok(command === 'unzip' || path.basename(command) === 'aapt', command); return { stdout: '', stderr: '', exitCode: 1 }; - }; + }); const inventory = async () => [launched ? { ...device, id: 'emulator-5554' } : device]; vi.spyOn(inventoryContext, 'listLocalDeviceInventory').mockImplementation(inventory); - vi.spyOn(hostCommands, 'runCmd').mockImplementation(async (command, args, options = {}) => { - const result = commands(command, args, options); - assert.ok(result, `Unscripted host command: ${command}`); - return await result; - }); const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => provider, deviceInventoryProvider: inventory, env: { PATH: tempRoot, ANDROID_HOME: tempRoot, ANDROID_SDK_ROOT: tempRoot }, }); @@ -453,22 +450,24 @@ function assertInstallLedgerPhaseCoverage( const expected = isAndroidRoute(route) ? [ 'sys.boot_completed', - ...(route === 'Android APK' ? ['adb-install'] : ['build-apks', 'install-apks']), + ...(route === 'Android APK' ? ['install'] : ['build-apks', 'install-apks']), ] : route === 'iOS simulator' ? ['list', 'boot', 'bootstatus', 'open', 'install'] : ['details', 'install']; + if (!isAndroidRoute(route)) expected.push('plutil', '-convert', '-extract'); if (command === 'reinstall') expected.push('uninstall'); if (isAndroidRoute(route)) { assert.ok( - ledger.filter((call) => call.args.join(' ') === 'shell pm list packages').length >= 2, + ledger.filter((call) => call.tool === 'adb' && call.args.join(' ').includes('packages')) + .length >= 2, 'missing before/after package inventories', ); if (command !== 'reinstall') expected.push('unzip'); } for (const phase of expected) { assert.ok( - ledger.some((call) => call.tool === phase || call.args.includes(phase)), + ledger.some((call) => call.tool === phase || call.args.some((arg) => arg.includes(phase))), `${route} ${command}: missing ${phase} phase. Ledger: ${JSON.stringify(ledger)}`, ); } From 5caa8045e83fdf4504d715d4736ce074df63d67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 10:43:50 +0100 Subject: [PATCH 04/18] fix(deployment): admit discovered emulator before installing --- .../src/descriptors/session-operations.ts | 4 +- .../src/app-deployment-runtime-plan.ts | 5 +- .../__tests__/session-reinstall.test.ts | 2 +- .../handlers/session-app-deployment.ts | 15 ++-- .../src/deployment/runtime.test.ts | 69 +++++++------------ .../src/deployment/runtime.ts | 4 +- .../install-timeout-ledger.test.ts | 36 +++++++++- 7 files changed, 80 insertions(+), 55 deletions(-) diff --git a/packages/command-registry/src/descriptors/session-operations.ts b/packages/command-registry/src/descriptors/session-operations.ts index 2bf9053dc6..1d82ead906 100644 --- a/packages/command-registry/src/descriptors/session-operations.ts +++ b/packages/command-registry/src/descriptors/session-operations.ts @@ -20,7 +20,7 @@ import { } from '../descriptor-traits.ts'; import { INSTALL_TIMEOUT_POLICY } from '../descriptor-timeout-policies.ts'; import { - deployAppUse, + readyDeployAppUse, readyMaterializeAndDeployAppUse, readySendPushNotificationUse, uninstallAppUse, @@ -50,7 +50,7 @@ const DEPLOY_APP_COMMAND_DESCRIPTOR = { recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, - platformExecution: { kind: 'device-runtime', use: deployAppUse }, + platformExecution: { kind: 'device-runtime', use: readyDeployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, compositeResource: 'none', diff --git a/packages/contracts/src/app-deployment-runtime-plan.ts b/packages/contracts/src/app-deployment-runtime-plan.ts index 69f166e42c..e0d1fae90e 100644 --- a/packages/contracts/src/app-deployment-runtime-plan.ts +++ b/packages/contracts/src/app-deployment-runtime-plan.ts @@ -1,12 +1,13 @@ import { defineUse } from './platform-runtime-operations.ts'; -export const deployAppUse = defineUse({ required: ['deployApp'] }); +export const readyDeployAppUse = defineUse({ required: ['ensureReady', 'deployApp'] }); export const uninstallAppUse = defineUse({ required: ['uninstallApp'] }); /** * Source installs retain their readiness precondition in the same request binding as - * materialization and deployment, so the handler has one admission and one bind. + * materialization and deployment. If readiness changes the device identity, the handler must + * admit and bind that device before deployment. */ export const readyMaterializeAndDeployAppUse = defineUse({ required: ['ensureReady', 'materializeAppSource', 'deployMaterializedApp'], diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts index 748cc2219d..33acdb62da 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts @@ -167,7 +167,7 @@ test('install binds the deployment use exactly once after one facts admission', expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime.mock.calls[0]?.[1]).toMatchObject({ - required: ['deployApp'], + required: ['ensureReady', 'deployApp'], }); }); diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index dfbb8a7aa4..0f217652b5 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -5,7 +5,7 @@ import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime'; import { - deployAppUse, + readyDeployAppUse, readySendPushNotificationUse, uninstallAppUse, } from '@agent-device/contracts/app-deployment-runtime-plan'; @@ -82,17 +82,24 @@ export async function handleAppDeploymentCommand(params: { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } - const device = await resolveCommandDevice({ + let device = await resolveCommandDevice({ session, flags, env: sessionStore.host.env, platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); - const unsupported = unavailableRuntimeOperationResponse(command, facts.operations.deployApp); + const unsupported = + unavailableRuntimeOperationResponse(command, facts.operations.ensureReady) ?? + unavailableRuntimeOperationResponse(command, facts.operations.deployApp); if (unsupported) return unsupported; - const runtime = await requireRuntimeBinding(params.bindDevice)(device, deployAppUse); + let runtime = await requireRuntimeBinding(params.bindDevice)(device, readyDeployAppUse); + const ready = await runtime.operations.ensureReady({}); + if (ready.id !== device.id) { + runtime = await requireRuntimeBinding(params.bindDevice)(ready, readyDeployAppUse); + } + device = ready; // ADR 0014: deployment can replace the visible surface. Do this immediately before the // bound operation so an admission failure never discards a still-valid reference frame. if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); diff --git a/packages/platform-android/src/deployment/runtime.test.ts b/packages/platform-android/src/deployment/runtime.test.ts index e36cdf9e4c..7a01d362a1 100644 --- a/packages/platform-android/src/deployment/runtime.test.ts +++ b/packages/platform-android/src/deployment/runtime.test.ts @@ -103,51 +103,34 @@ test('exposes the complete Android deployment operation set only for admitted ki ).toEqual({}); }); -test.each([false, true])( - 'uses the discovered Android serial for deployment (replaceExisting=%s)', - async (replaceExisting) => { - const order: string[] = []; - ensureAndroidReady.mockImplementationOnce(async () => { - order.push('ready'); - return device; - }); - const host = hostFixture({ - prepareArtifact: async () => { - order.push('prepare'); - return { installablePath: '/tmp/app.apk', cleanup: async () => {} }; - }, - }); - native.installAndroidArtifact.mockResolvedValueOnce(undefined); +test('waits for Android readiness before inspecting an install artifact', async () => { + const order: string[] = []; + ensureAndroidReady.mockImplementationOnce(async () => { + order.push('ready'); + return device; + }); + const host = hostFixture({ + prepareArtifact: async () => { + order.push('prepare'); + return { installablePath: '/tmp/app.apk', cleanup: async () => {} }; + }, + }); + native.installAndroidArtifact.mockResolvedValueOnce(undefined); - await createAndroidAppDeploymentOperations({ - host, - device: { ...device, id: 'stopped-avd', booted: false }, - signal: new AbortController().signal, - }).deployApp?.({ app: 'com.example.app', appPath: '/tmp/app.apk', replaceExisting }); + await createAndroidAppDeploymentOperations({ + host, + device: { ...device, booted: false }, + signal: new AbortController().signal, + }).deployApp?.({ app: '', appPath: '/tmp/app.apk', replaceExisting: false }); - expect(ensureAndroidReady).toHaveBeenCalledWith( - host, - { ...device, id: 'stopped-avd', booted: false }, - { headless: false }, - expect.any(AbortSignal), - ); - expect(order).toEqual(['ready', 'prepare']); - expect(native.installAndroidArtifact).toHaveBeenCalledWith( - host, - device, - '/tmp/app.apk', - undefined, - expect.any(AbortSignal), - ); - if (replaceExisting) - expect(native.uninstallAndroidPackage).toHaveBeenCalledWith( - host, - device, - 'com.example.app', - expect.any(AbortSignal), - ); - }, -); + expect(ensureAndroidReady).toHaveBeenCalledWith( + host, + { ...device, booted: false }, + { headless: false }, + expect.any(AbortSignal), + ); + expect(order).toEqual(['ready', 'prepare']); +}); test('preserves reinstall partial-failure ordering and whole-operation cache invalidation', async () => { const order: string[] = []; diff --git a/packages/platform-android/src/deployment/runtime.ts b/packages/platform-android/src/deployment/runtime.ts index c854f0f7e8..877dccc1f4 100644 --- a/packages/platform-android/src/deployment/runtime.ts +++ b/packages/platform-android/src/deployment/runtime.ts @@ -105,7 +105,7 @@ async function deployAndroidApp( let artifact: MaterializedAppSource | undefined; try { if (device.booted !== true) { - device = await ensureAndroidReady(host, device, { headless: false }, signal); + await ensureAndroidReady(host, device, { headless: false }, signal); } const packageName = await host.androidDeployment.resolveAppPackage(device, input.app); await uninstallAndroidPackage(host, device, packageName, signal); @@ -127,7 +127,7 @@ async function deployAndroidApp( let artifact: MaterializedAppSource | undefined; try { if (device.booted !== true) { - device = await ensureAndroidReady(host, device, { headless: false }, signal); + await ensureAndroidReady(host, device, { headless: false }, signal); } artifact = await host.androidDeployment.prepareArtifact( { source: { kind: 'path', path: input.appPath } }, diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 36b1b2b3a7..140a60a937 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -1,3 +1,4 @@ +import { acquireDeviceClaim } from '@agent-device/daemon-contracts/device-claims'; import * as inventoryContext from '@agent-device/device-selection/device-inventory-context'; import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; @@ -70,6 +71,7 @@ class InstallLedger { type InstallLedgerScenario = ClosableProviderScenarioResource & Readonly<{ appPath: string; + claimsDir?: string; daemon: ProviderScenarioHarness; ledger: InstallLedger; selection: Readonly< @@ -102,6 +104,32 @@ for (const route of [ } } +for (const command of ['install', 'reinstall', 'install_source'] as const) { + test(`${command} admits the discovered emulator before deployment`, async () => { + await withInstallLedgerScenario('Android APK', async (scenario) => { + assert.ok(scenario.claimsDir); + await acquireDeviceClaim({ + env: { AGENT_DEVICE_CLAIMS_DIR: scenario.claimsDir }, + device: PROVIDER_SCENARIO_ANDROID, + session: 'foreign-session', + workspace: '/foreign-worktree', + stateDir: path.join(path.dirname(scenario.daemon.stateDir), 'foreign-state'), + reconcileOrphanedDeviceClaim: async () => ({ + status: 'retained', + reason: 'live-test-owner', + }), + }); + await assert.rejects(runInstallCommand(scenario, command), { code: 'DEVICE_IN_USE' }); + assert.ok(scenario.ledger.calls.some((call) => call.args.includes('sys.boot_completed'))); + assert.ok( + !scenario.ledger.calls.some( + (call) => call.args.includes('install') || call.args.includes('uninstall'), + ), + ); + }); + }); +} + async function withInstallLedgerScenario( route: InstallRoute, run: (scenario: InstallLedgerScenario) => Promise, @@ -387,13 +415,19 @@ async function createAndroidScenario( vi.spyOn(inventoryContext, 'listLocalDeviceInventory').mockImplementation(inventory); const daemon = await createProviderScenarioHarness({ deviceInventoryProvider: inventory, - env: { PATH: tempRoot, ANDROID_HOME: tempRoot, ANDROID_SDK_ROOT: tempRoot }, + env: { + PATH: tempRoot, + ANDROID_HOME: tempRoot, + ANDROID_SDK_ROOT: tempRoot, + AGENT_DEVICE_CLAIMS_DIR: path.join(tempRoot, 'claims'), + }, }); return { appPath, daemon, ledger, selection: { platform: 'android', device: device.name }, + claimsDir: path.join(tempRoot, 'claims'), close: async () => { await daemon.close(); }, From 004d732b59f9320b0725c955a9032f11d9d7ddc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 10:49:14 +0100 Subject: [PATCH 05/18] refactor(tests): separate Android boot polling from host command dispatch --- .../install-timeout-ledger.test.ts | 66 +++++++++---------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 140a60a937..c26311d211 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -367,49 +367,43 @@ async function createAndroidScenario( launched = true; return 4242; }); - vi.spyOn(hostCommands, 'runCmd').mockImplementation(async (command, args, options = {}) => { - if (command === 'adb') { - assert.equal(args[args.indexOf('-s') + 1], 'emulator-5554'); - } - if (command === 'adb' && args.some((arg) => arg.includes('sys.boot_completed'))) { + const ok = { stdout: '', stderr: '', exitCode: 0 }; + function runAdb(args: readonly string[], options: hostCommands.ExecOptions) { + assert.equal(args[args.indexOf('-s') + 1], 'emulator-5554'); + if (args.some((arg) => arg.includes('sys.boot_completed'))) { assert.ok(options.timeoutMs); bootProbeCapMs ??= options.timeoutMs; const ready = options.timeoutMs < bootProbeCapMs; // Leave room for the poll sleep; complete only when the real boot deadline narrows a probe. - ledger.record( - command, - args, - options.timeoutMs, - ready ? undefined : options.timeoutMs - 1_001, - ); - return { stdout: ready ? '1' : '0', stderr: '', exitCode: 0 }; + ledger.record('adb', args, options.timeoutMs, ready ? undefined : options.timeoutMs - 1_001); + return { ...ok, stdout: ready ? '1' : '0' }; } - ledger.record(command, args, options.timeoutMs); - if (command === '/usr/bin/lsappinfo') return { stdout: '', stderr: '', exitCode: 0 }; - if (command === '/usr/sbin/sysctl') return { stdout: '1', stderr: '', exitCode: 0 }; - if (command === 'adb') { - if (args.includes('install')) installed = true; - if (args.includes('uninstall')) installed = false; - return { - stdout: - args.includes('shell') && args.join(' ').includes('packages') - ? installed - ? 'package:com.example.demo\n' - : '' - : 'Success', - stderr: '', - exitCode: 0, - }; + ledger.record('adb', args, options.timeoutMs); + if (args.includes('install')) installed = true; + if (args.includes('uninstall')) installed = false; + if (args.join(' ').includes('packages')) { + return { ...ok, stdout: installed ? 'package:com.example.demo\n' : '' }; } - if (command === 'bundletool') { - if (args.includes('install-apks')) { - assert.equal(args[args.indexOf('--device-id') + 1], 'emulator-5554'); - installed = true; - } - return { stdout: '', stderr: '', exitCode: 0 }; + return { ...ok, stdout: 'Success' }; + } + vi.spyOn(hostCommands, 'runCmd').mockImplementation(async (command, args, options = {}) => { + if (command === 'adb') return runAdb(args, options); + ledger.record(command, args, options.timeoutMs); + switch (command) { + case '/usr/bin/lsappinfo': + return ok; + case '/usr/sbin/sysctl': + return { ...ok, stdout: '1' }; + case 'bundletool': + if (args.includes('install-apks')) { + assert.equal(args[args.indexOf('--device-id') + 1], 'emulator-5554'); + installed = true; + } + return ok; + default: + assert.ok(command === 'unzip' || path.basename(command) === 'aapt', command); + return { ...ok, exitCode: 1 }; } - assert.ok(command === 'unzip' || path.basename(command) === 'aapt', command); - return { stdout: '', stderr: '', exitCode: 1 }; }); const inventory = async () => [launched ? { ...device, id: 'emulator-5554' } : device]; vi.spyOn(inventoryContext, 'listLocalDeviceInventory').mockImplementation(inventory); From fb1a32f2cf3c92b7ddeff40174fddbf4b3962a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 10:54:58 +0100 Subject: [PATCH 06/18] fix(deployment): keep readiness conditional for provider-owned installs --- .../install-timeout-ledger-2026-10-30.md | 125 ++++++++++++++++++ .../src/descriptors/session-operations.ts | 4 +- .../src/app-deployment-runtime-plan.ts | 2 + .../__tests__/session-reinstall.test.ts | 3 +- .../handlers/session-app-deployment.ts | 22 +-- 5 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 docs/evidence/install-timeout-ledger-2026-10-30.md diff --git a/docs/evidence/install-timeout-ledger-2026-10-30.md b/docs/evidence/install-timeout-ledger-2026-10-30.md new file mode 100644 index 0000000000..ac89acec2e --- /dev/null +++ b/docs/evidence/install-timeout-ledger-2026-10-30.md @@ -0,0 +1,125 @@ +# Install timeout verification — 2026-10-30 + +Live source SHA: `9eea80f2a583e7b6b05d187114922506ee1fa3d0`. Subsequent changes simplify the test fake and preserve provider deployments that do not expose a separate readiness operation. Install subprocess bounds are unchanged. + +Devices: Android 15 emulator `ReactNative_API_35B` (`emulator-5554`) and iOS 27.0 simulator `iPhone 17 Pro` (`BC54AC11-CD86-4C10-8C4E-5101C773FFEB`). The fixtures use `com.callstack.installtimeoutproof`. + +The device-panel CLI sent install, reinstall, and URL-source commands to an isolated daemon built from this checkout. The Node API sent path-source commands because the CLI accepts only URL and CI sources. URL payloads were served through a temporary public HTTPS tunnel. Android AAB commands used the local bundletool fallback, with its two subprocesses bounded independently. + +All 12 matrix cases succeeded. Each source case started after uninstalling the fixture. The final cleanup uninstalled it. `AGENT_DEVICE_EXEC_TRACE=true` saved subprocess bounds to each request log. + +## Request evidence + +### android-apk: install + +Exit 0, success true. Request `2315096768d37af3`. + +```json +[{"command":"unzip","timeoutMs":15000},{"command":"adb","timeoutMs":120000}] +``` + +### android-apk: reinstall + +Exit 0, success true. Request `2f017324c2745848`. + +```json +[{"command":"adb","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"adb","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] +``` + +### android-apk: source-path + +Exit 0, success true. Request `a85ed6f4e7cfc7bf`. + +```json +[{"command":"unzip","timeoutMs":15000},{"command":"adb","timeoutMs":120000}] +``` + +### android-apk: source-url + +Exit 0, success true. Request `8dc8f16f9eb99abe`. + +```json +[{"command":"unzip","timeoutMs":15000},{"command":"adb","timeoutMs":120000}] +``` + +### android-aab: install + +Exit 0, success true. Request `7a2adfa89e465908`. + +```json +[{"command":"unzip","timeoutMs":15000},{"command":"unzip","timeoutMs":15000},{"command":"aapt","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] +``` + +### android-aab: reinstall + +Exit 0, success true. Request `6af1acfe3ea0871b`. + +```json +[{"command":"adb","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] +``` + +### android-aab: source-path + +Exit 0, success true. Request `bb9b953af572935e`. + +```json +[{"command":"unzip","timeoutMs":15000},{"command":"unzip","timeoutMs":15000},{"command":"aapt","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] +``` + +### android-aab: source-url + +Exit 0, success true. Request `6b54c958085fee22`. + +```json +[{"command":"unzip","timeoutMs":15000},{"command":"unzip","timeoutMs":15000},{"command":"aapt","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] +``` + +### ios-simulator: install + +Exit 0, success true. Request `47d1332d8f86ea45`. + +```json +[{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] +``` + +### ios-simulator: reinstall + +Exit 0, success true. Request `c99b595555fed5bc`. + +```json +[{"command":"xcrun","timeoutMs":20000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] +``` + +### ios-simulator: source-path + +Exit 0, success true. Request `edc59a0093f390e8`. + +```json +[{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] +``` + +### ios-simulator: source-url + +Exit 0, success true. Request `fe50368be865a97a`. + +```json +[{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] +``` + +## Ledger regression proof + +At `9eea80f2a583e7b6b05d187114922506ee1fa3d0`, the same focused provider suite ran in a disposable checkout: + +- Unmodified head: 19 passed. +- Restore the PR’s 330s/420s envelopes: 9 failed, 10 passed; failures report the missing daemon-result margin. +- Raise the real Android install/bundletool bound from 120s to 200s: 4 failed, 15 passed; the ledger captures the changed production call bounds. + +Command: `pnpm exec vitest run --project provider-integration test/integration/provider-scenarios/install-timeout-ledger.test.ts --reporter verbose`. The disposable checkout was removed. + +The ledger covers shutdown Android AVD discovery, boot polling, the changed serial, foreign device claims, metadata inspection, uninstall, APK install, both bundletool phases, simulator boot, CoreDevice readiness, and URL download. Archive extraction remains bounded by size, not wall-clock time. Provider-owned install implementations remain responsible for honoring the supplied timeout. + +## Limits + +No physical iOS device was available; its route has provider integration coverage. A separate live shutdown-AVD attempt stopped at selector validation: the device-panel CLI pins a running serial, does not select a stopped AVD by serial, and rejects clearing it. The 12 successful matrix cases used already booted targets. + +No app session was opened. The temporary daemon, file server, and HTTPS tunnel are stopped after validation. diff --git a/packages/command-registry/src/descriptors/session-operations.ts b/packages/command-registry/src/descriptors/session-operations.ts index 1d82ead906..2bf9053dc6 100644 --- a/packages/command-registry/src/descriptors/session-operations.ts +++ b/packages/command-registry/src/descriptors/session-operations.ts @@ -20,7 +20,7 @@ import { } from '../descriptor-traits.ts'; import { INSTALL_TIMEOUT_POLICY } from '../descriptor-timeout-policies.ts'; import { - readyDeployAppUse, + deployAppUse, readyMaterializeAndDeployAppUse, readySendPushNotificationUse, uninstallAppUse, @@ -50,7 +50,7 @@ const DEPLOY_APP_COMMAND_DESCRIPTOR = { recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, - platformExecution: { kind: 'device-runtime', use: readyDeployAppUse }, + platformExecution: { kind: 'device-runtime', use: deployAppUse }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, batchable: true, compositeResource: 'none', diff --git a/packages/contracts/src/app-deployment-runtime-plan.ts b/packages/contracts/src/app-deployment-runtime-plan.ts index e0d1fae90e..11758d2e6f 100644 --- a/packages/contracts/src/app-deployment-runtime-plan.ts +++ b/packages/contracts/src/app-deployment-runtime-plan.ts @@ -1,5 +1,7 @@ import { defineUse } from './platform-runtime-operations.ts'; +export const deployAppUse = defineUse({ required: ['deployApp'], conditional: ['ensureReady'] }); + export const readyDeployAppUse = defineUse({ required: ['ensureReady', 'deployApp'] }); export const uninstallAppUse = defineUse({ required: ['uninstallApp'] }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts index 33acdb62da..e5b73df39d 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-reinstall.test.ts @@ -167,7 +167,8 @@ test('install binds the deployment use exactly once after one facts admission', expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); expect(mockBindDeviceRuntime.mock.calls[0]?.[1]).toMatchObject({ - required: ['ensureReady', 'deployApp'], + required: ['deployApp'], + conditional: ['ensureReady'], }); }); diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 0f217652b5..443ecab52a 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -5,6 +5,7 @@ import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime'; import { + deployAppUse, readyDeployAppUse, readySendPushNotificationUse, uninstallAppUse, @@ -89,17 +90,22 @@ export async function handleAppDeploymentCommand(params: { platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); - const unsupported = - unavailableRuntimeOperationResponse(command, facts.operations.ensureReady) ?? - unavailableRuntimeOperationResponse(command, facts.operations.deployApp); + const unsupported = unavailableRuntimeOperationResponse(command, facts.operations.deployApp); if (unsupported) return unsupported; - let runtime = await requireRuntimeBinding(params.bindDevice)(device, readyDeployAppUse); - const ready = await runtime.operations.ensureReady({}); - if (ready.id !== device.id) { - runtime = await requireRuntimeBinding(params.bindDevice)(ready, readyDeployAppUse); + let runtime = await requireRuntimeBinding(params.bindDevice)(device, deployAppUse); + if ( + runtime.owner.kind === 'local-family' && + device.platform === 'android' && + device.booted !== true + ) { + const readyRuntime = await requireRuntimeBinding(params.bindDevice)( + device, + readyDeployAppUse, + ); + device = await readyRuntime.operations.ensureReady({}); + runtime = await requireRuntimeBinding(params.bindDevice)(device, deployAppUse); } - device = ready; // ADR 0014: deployment can replace the visible surface. Do this immediately before the // bound operation so an admission failure never discards a still-valid reference frame. if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); From 3ac9c4780420cacf73e7fd0634a08744cede8c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 11:00:03 +0100 Subject: [PATCH 07/18] fix(runtime): bind fresh boot observations before deployment --- .../install-timeout-ledger-2026-10-30.md | 2 +- .../__tests__/request-runtime-binding.test.ts | 17 +++++++++++++++++ .../daemon/src/core/request-runtime-binding.ts | 2 +- .../install-timeout-ledger.test.ts | 16 +++++++++++----- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/evidence/install-timeout-ledger-2026-10-30.md b/docs/evidence/install-timeout-ledger-2026-10-30.md index ac89acec2e..f75f35ea36 100644 --- a/docs/evidence/install-timeout-ledger-2026-10-30.md +++ b/docs/evidence/install-timeout-ledger-2026-10-30.md @@ -116,7 +116,7 @@ At `9eea80f2a583e7b6b05d187114922506ee1fa3d0`, the same focused provider suite r Command: `pnpm exec vitest run --project provider-integration test/integration/provider-scenarios/install-timeout-ledger.test.ts --reporter verbose`. The disposable checkout was removed. -The ledger covers shutdown Android AVD discovery, boot polling, the changed serial, foreign device claims, metadata inspection, uninstall, APK install, both bundletool phases, simulator boot, CoreDevice readiness, and URL download. Archive extraction remains bounded by size, not wall-clock time. Provider-owned install implementations remain responsible for honoring the supplied timeout. +The final ledger also covers a running emulator that becomes ready without changing its serial. Request binding caches distinguish the old and fresh boot observations. The ledger covers shutdown Android AVD discovery, boot polling, the changed serial, foreign device claims, metadata inspection, uninstall, APK install, both bundletool phases, simulator boot, CoreDevice readiness, and URL download. Archive extraction remains bounded by size, not wall-clock time. Provider-owned install implementations remain responsible for honoring the supplied timeout. ## Limits diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding.test.ts index 25c337103e..850116b554 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding.test.ts @@ -134,6 +134,23 @@ test('request runtime binding caches one broad owner and projects each declared expect(runtime.disposals).toEqual(['one']); }); +test('a readiness transition rebinds the same device with its fresh boot observation', async () => { + const runtime = makeGateway(); + const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), + gateway: runtime.gateway, + scope, + admitDeviceClaim, + }); + const selected = { ...device('one'), booted: false }; + const before = await bindings.bindDevice(selected, appLogInspectUse); + const after = await bindings.bindDevice({ ...selected, booted: true }, appLogInspectUse); + + expect(before.device).toMatchObject({ id: 'one', booted: false }); + expect(after.device).toMatchObject({ id: 'one', booted: true }); + await bindings[Symbol.asyncDispose](); +}); + test('facts inspection answers admission without creating a request binding', async () => { const runtime = makeGateway(); const bindings = createRequestRuntimeBindings({ diff --git a/packages/daemon/src/core/request-runtime-binding.ts b/packages/daemon/src/core/request-runtime-binding.ts index 9736622b51..80f97475c8 100644 --- a/packages/daemon/src/core/request-runtime-binding.ts +++ b/packages/daemon/src/core/request-runtime-binding.ts @@ -126,7 +126,7 @@ export function createRequestRuntimeBindings(params: { const bindDevice: BindDeviceRuntime = async (device, use) => { params.admitDevice?.(device); - const key = deviceIdentityKey(deviceIdentity(device)); + const key = JSON.stringify([deviceIdentityKey(deviceIdentity(device)), device.booted === true]); let bindingPromise = bindings.get(key); if (!bindingPromise) { const intent: DeviceBindingIntent = { kind: 'ordinary' }; diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index c26311d211..5ea3c44b05 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -47,7 +47,8 @@ type InstallRoute = | 'iOS physical device' | 'iOS simulator' | 'Android APK' - | 'Android AAB fallback'; + | 'Android AAB fallback' + | 'Android AAB booting'; class InstallLedger { readonly calls: InstallLedgerCall[] = []; @@ -87,6 +88,7 @@ for (const route of [ 'iOS simulator', 'Android APK', 'Android AAB fallback', + 'Android AAB booting', ] as const) { for (const command of ['install', 'reinstall', 'install_source', 'install_source URL'] as const) { test(`${route} ${command} leaves 30s for the daemon result`, async () => { @@ -346,7 +348,7 @@ function writeCoreDeviceDetailsJson(args: readonly string[]): void { } async function createAndroidScenario( - route: 'Android APK' | 'Android AAB fallback', + route: Extract, ledger: InstallLedger, ): Promise { const tempRoot = mkdtempForTestSync('agent-device-install-ledger-android-'); @@ -359,9 +361,13 @@ async function createAndroidScenario( fs.mkdirSync(path.dirname(aaptPath), { recursive: true }); fs.writeFileSync(aaptPath, '', { mode: 0o755 }); let installed = false; - let launched = false; + let launched = route === 'Android AAB booting'; let bootProbeCapMs: number | undefined; - const device = { ...PROVIDER_SCENARIO_ANDROID, id: 'Pixel_8', booted: false }; + const device = { + ...PROVIDER_SCENARIO_ANDROID, + id: launched ? 'emulator-5554' : 'Pixel_8', + booted: false, + }; vi.spyOn(hostCommands, 'runCmdDetached').mockImplementation((command) => { assert.equal(command, 'emulator'); launched = true; @@ -436,7 +442,7 @@ function downloadBody(appPath: string): Readable { return archive; } -function isAndroidRoute(route: InstallRoute): route is 'Android APK' | 'Android AAB fallback' { +function isAndroidRoute(route: InstallRoute): route is Extract { return route.startsWith('Android'); } From 35d574446992667cebaab2c06fa60456a6e913d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 11:04:32 +0100 Subject: [PATCH 08/18] fix(timeouts): limit deployment bounds to install-family adb calls --- .../platform-android/src/deployment/native.test.ts | 1 + packages/platform-android/src/deployment/native.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/deployment/native.test.ts b/packages/platform-android/src/deployment/native.test.ts index 7c9aa6755e..f5b65284b3 100644 --- a/packages/platform-android/src/deployment/native.test.ts +++ b/packages/platform-android/src/deployment/native.test.ts @@ -136,6 +136,7 @@ test('preserves missing-package uninstall and typed push extras', async () => { expect(run).toHaveBeenLastCalledWith( expect.objectContaining({ args: expect.arrayContaining(['--es', 'title', 'Hello', '--ez', 'enabled', 'true']), + timeoutMs: undefined, }), signal, ); diff --git a/packages/platform-android/src/deployment/native.ts b/packages/platform-android/src/deployment/native.ts index 16eeb2fc3b..4576407030 100644 --- a/packages/platform-android/src/deployment/native.ts +++ b/packages/platform-android/src/deployment/native.ts @@ -39,7 +39,13 @@ export async function uninstallAndroidPackage( packageName: string, signal: AbortSignal, ): Promise { - const result = await runAdb(host, device, ['uninstall', packageName], signal); + const result = await runAdb( + host, + device, + ['uninstall', packageName], + signal, + ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, + ); if (result.exitCode === 0) return; const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (output.includes('unknown package') || output.includes('not installed')) return; @@ -175,6 +181,7 @@ async function listInstalledPackages( device, deviceShellArgv('adb', 'shell', ['pm', 'list', 'packages']), signal, + ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, ); assertCommandSuccess(result, 'adb package inventory failed'); return new Set( @@ -203,7 +210,7 @@ async function runAdb( device: DeviceInfo, args: readonly string[], signal: AbortSignal, - timeoutMs = ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, + timeoutMs?: number, ) { return await host.androidTools.runAdb(device, args, { timeoutMs, allowFailure: true }, signal); } From 85532377d743fac8fc6b59c4a1fb438d0e1b3022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 11:11:08 +0100 Subject: [PATCH 09/18] refactor(apple): keep plist timeout with its tool executor --- packages/platform-apple/src/core/config.ts | 1 - packages/platform-apple/src/core/plist.ts | 7 +++++-- packages/platform-apple/src/core/tool-provider.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/platform-apple/src/core/config.ts b/packages/platform-apple/src/core/config.ts index 1bc8f7dbea..898dfe8fd2 100644 --- a/packages/platform-apple/src/core/config.ts +++ b/packages/platform-apple/src/core/config.ts @@ -9,7 +9,6 @@ export const IOS_DEVICECTL_TIMEOUT_MS = 20_000; export const IOS_DEVICECTL_MIN_TIMEOUT_SECONDS = 5; export const IOS_DEVICE_INSTALL_TIMEOUT_MS = 120_000; -export const APPLE_PLIST_TIMEOUT_MS = 15_000; export const IOS_APP_UNINSTALL_TIMEOUT_MS = 20_000; export const IOS_SIMULATOR_SHOW_TIMEOUT_MS = 10_000; diff --git a/packages/platform-apple/src/core/plist.ts b/packages/platform-apple/src/core/plist.ts index ba81d7b8ff..97c40e0cb2 100644 --- a/packages/platform-apple/src/core/plist.ts +++ b/packages/platform-apple/src/core/plist.ts @@ -1,7 +1,10 @@ -import { APPLE_PLIST_TIMEOUT_MS } from './config.ts'; import { readHostTextFile } from '@agent-device/host-kit/host-file'; import { parseXmlDocumentSync, visitXmlPlistEntries } from '@agent-device/xml'; -import { readApplePlistJson, runAppleToolCommand } from './tool-provider.ts'; +import { + APPLE_PLIST_TIMEOUT_MS, + readApplePlistJson, + runAppleToolCommand, +} from './tool-provider.ts'; export async function readInfoPlistString( infoPlistPath: string, diff --git a/packages/platform-apple/src/core/tool-provider.ts b/packages/platform-apple/src/core/tool-provider.ts index aca3b72c08..c04dddd765 100644 --- a/packages/platform-apple/src/core/tool-provider.ts +++ b/packages/platform-apple/src/core/tool-provider.ts @@ -1,4 +1,3 @@ -import { APPLE_PLIST_TIMEOUT_MS } from './config.ts'; import { coerceExecResult, runCmd, @@ -61,6 +60,8 @@ export type AppleToolProvider = { whichCommand: AppleToolAvailabilityChecker; }; +export const APPLE_PLIST_TIMEOUT_MS = 15_000; + const localAppleToolProvider: AppleToolProvider = { runCommand: runCmd, simctl: { From 168e71c25241f291b9dd2dcdc536a06a90192793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 12:29:56 +0100 Subject: [PATCH 10/18] refactor(tests): reuse tool executors in install timeout coverage --- .../install-timeout-ledger-2026-10-30.md | 111 ++--------- .../install-timeout-ledger.test.ts | 174 +++++++----------- 2 files changed, 85 insertions(+), 200 deletions(-) diff --git a/docs/evidence/install-timeout-ledger-2026-10-30.md b/docs/evidence/install-timeout-ledger-2026-10-30.md index f75f35ea36..28807fdd4c 100644 --- a/docs/evidence/install-timeout-ledger-2026-10-30.md +++ b/docs/evidence/install-timeout-ledger-2026-10-30.md @@ -10,101 +10,22 @@ All 12 matrix cases succeeded. Each source case started after uninstalling the f ## Request evidence -### android-apk: install - -Exit 0, success true. Request `2315096768d37af3`. - -```json -[{"command":"unzip","timeoutMs":15000},{"command":"adb","timeoutMs":120000}] -``` - -### android-apk: reinstall - -Exit 0, success true. Request `2f017324c2745848`. - -```json -[{"command":"adb","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"adb","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] -``` - -### android-apk: source-path - -Exit 0, success true. Request `a85ed6f4e7cfc7bf`. - -```json -[{"command":"unzip","timeoutMs":15000},{"command":"adb","timeoutMs":120000}] -``` - -### android-apk: source-url - -Exit 0, success true. Request `8dc8f16f9eb99abe`. - -```json -[{"command":"unzip","timeoutMs":15000},{"command":"adb","timeoutMs":120000}] -``` - -### android-aab: install - -Exit 0, success true. Request `7a2adfa89e465908`. - -```json -[{"command":"unzip","timeoutMs":15000},{"command":"unzip","timeoutMs":15000},{"command":"aapt","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] -``` - -### android-aab: reinstall - -Exit 0, success true. Request `6af1acfe3ea0871b`. - -```json -[{"command":"adb","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] -``` - -### android-aab: source-path - -Exit 0, success true. Request `bb9b953af572935e`. - -```json -[{"command":"unzip","timeoutMs":15000},{"command":"unzip","timeoutMs":15000},{"command":"aapt","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] -``` - -### android-aab: source-url - -Exit 0, success true. Request `6b54c958085fee22`. - -```json -[{"command":"unzip","timeoutMs":15000},{"command":"unzip","timeoutMs":15000},{"command":"aapt","timeoutMs":15000},{"command":"adb","timeoutMs":15000},{"command":"bundletool","timeoutMs":120000},{"command":"bundletool","timeoutMs":120000},{"command":"adb","timeoutMs":15000}] -``` - -### ios-simulator: install - -Exit 0, success true. Request `47d1332d8f86ea45`. - -```json -[{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] -``` - -### ios-simulator: reinstall - -Exit 0, success true. Request `c99b595555fed5bc`. - -```json -[{"command":"xcrun","timeoutMs":20000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] -``` - -### ios-simulator: source-path - -Exit 0, success true. Request `edc59a0093f390e8`. - -```json -[{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] -``` - -### ios-simulator: source-url - -Exit 0, success true. Request `fe50368be865a97a`. - -```json -[{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"plutil","timeoutMs":15000},{"command":"xcrun","timeoutMs":120000}] -``` +All rows exited with code 0 and returned success. Timeouts are in milliseconds; repeated calls remain in their recorded order. + +| Route | Operation | Request | Recorded timeouts | +| --- | --- | --- | --- | +| android-apk | install | `2315096768d37af3` | unzip=15000, adb=120000 | +| android-apk | reinstall | `2f017324c2745848` | adb=15000, adb=15000, adb=120000, adb=15000 | +| android-apk | source-path | `a85ed6f4e7cfc7bf` | unzip=15000, adb=120000 | +| android-apk | source-url | `8dc8f16f9eb99abe` | unzip=15000, adb=120000 | +| android-aab | install | `7a2adfa89e465908` | unzip=15000, unzip=15000, aapt=15000, adb=15000, bundletool=120000, bundletool=120000, adb=15000 | +| android-aab | reinstall | `6af1acfe3ea0871b` | adb=15000, adb=15000, bundletool=120000, bundletool=120000, adb=15000 | +| android-aab | source-path | `bb9b953af572935e` | unzip=15000, unzip=15000, aapt=15000, adb=15000, bundletool=120000, bundletool=120000, adb=15000 | +| android-aab | source-url | `6b54c958085fee22` | unzip=15000, unzip=15000, aapt=15000, adb=15000, bundletool=120000, bundletool=120000, adb=15000 | +| ios-simulator | install | `47d1332d8f86ea45` | plutil=15000, plutil=15000, plutil=15000, xcrun=120000 | +| ios-simulator | reinstall | `c99b595555fed5bc` | xcrun=20000, plutil=15000, plutil=15000, plutil=15000, xcrun=120000 | +| ios-simulator | source-path | `edc59a0093f390e8` | plutil=15000, plutil=15000, plutil=15000, xcrun=120000 | +| ios-simulator | source-url | `fe50368be865a97a` | plutil=15000, plutil=15000, plutil=15000, xcrun=120000 | ## Ledger regression proof diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 5ea3c44b05..1fa21247e4 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -3,10 +3,6 @@ import * as inventoryContext from '@agent-device/device-selection/device-invento import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; import * as hostCommands from '@agent-device/host-kit/command'; -import { - withCommandExecutorOverride, - type CommandExecutorOverride, -} from '@agent-device/host-kit/command'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import * as timers from 'node:timers/promises'; import { pack } from 'tar-stream'; @@ -36,6 +32,7 @@ import { vi.mock('node:timers/promises', async (original) => ({ ...(await original()) })); type InstallCommand = 'install' | 'reinstall' | 'install_source'; +type InstallCase = InstallCommand | 'install_source URL'; type InstallLedgerCall = Readonly<{ tool: string; @@ -93,8 +90,7 @@ for (const route of [ for (const command of ['install', 'reinstall', 'install_source', 'install_source URL'] as const) { test(`${route} ${command} leaves 30s for the daemon result`, async () => { await withInstallLedgerScenario(route, async (scenario) => { - if (command === 'install_source URL') await runInstallSourceFromUrlCommand(scenario); - else await runInstallCommand(scenario, command); + await runInstallCommand(scenario, command); const daemonCommand = command === 'install_source URL' ? 'install_source' : command; assertInstallLedgerPhaseCoverage(route, daemonCommand, scenario.ledger.calls); if (command === 'install_source URL') { @@ -159,57 +155,53 @@ async function withInstallLedgerScenario( async function runInstallCommand( scenario: InstallLedgerScenario, - command: InstallCommand, + command: InstallCase, ): Promise { const client = scenario.daemon.client(); - if (command === 'install') { - await client.apps.install({ - app: 'com.example.demo', - appPath: scenario.appPath, - ...scenario.selection, - }); - return; - } - if (command === 'reinstall') { - await client.apps.reinstall({ + if (command === 'install' || command === 'reinstall') { + await client.apps[command]({ app: 'com.example.demo', appPath: scenario.appPath, ...scenario.selection, }); return; } + const fromUrl = command === 'install_source URL'; + if (fromUrl) stubSourceDownload(scenario); await client.apps.installFromSource({ - source: { kind: 'path', path: scenario.appPath }, + source: fromUrl + ? { + kind: 'url', + url: `https://artifacts.example.com/${path.basename(scenario.appPath)}${scenario.appPath.endsWith('.app') ? '.tar' : ''}`, + } + : { kind: 'path', path: scenario.appPath }, ...scenario.selection, }); } -async function runInstallSourceFromUrlCommand(scenario: InstallLedgerScenario): Promise { +function stubSourceDownload(scenario: InstallLedgerScenario): void { const timeouts = new WeakMap(); const timeout = AbortSignal.timeout.bind(AbortSignal); const any = AbortSignal.any.bind(AbortSignal); - const timeoutMock = vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => { + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => { const signal = timeout(ms); timeouts.set(signal, ms); return signal; }); - const anyMock = vi.spyOn(AbortSignal, 'any').mockImplementation((signals) => { + vi.spyOn(AbortSignal, 'any').mockImplementation((signals) => { const signal = any(signals); const ms = signals.map((part) => timeouts.get(part)).find((value) => value !== undefined); if (ms !== undefined) timeouts.set(signal, ms); return signal; }); - const lookupMock = vi - .spyOn(dns, 'lookup') - .mockImplementation( - async () => - [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< - ReturnType - >, - ); - const requestMock = vi - .spyOn(networkTransport, 'requestApprovedUrl') - .mockImplementation(async (request): Promise => { + vi.spyOn(dns, 'lookup').mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + vi.spyOn(networkTransport, 'requestApprovedUrl').mockImplementation( + async (request): Promise => { scenario.ledger.record('download', [request.url.toString()], timeouts.get(request.signal)); return { statusCode: 200, @@ -217,21 +209,8 @@ async function runInstallSourceFromUrlCommand(scenario: InstallLedgerScenario): body: downloadBody(scenario.appPath), close: async () => {}, }; - }); - try { - await scenario.daemon.client().apps.installFromSource({ - source: { - kind: 'url', - url: `https://artifacts.example.com/${path.basename(scenario.appPath)}${scenario.appPath.endsWith('.app') ? '.tar' : ''}`, - }, - ...scenario.selection, - }); - } finally { - requestMock.mockRestore(); - lookupMock.mockRestore(); - anyMock.mockRestore(); - timeoutMock.mockRestore(); - } + }, + ); } async function createIosScenario( @@ -252,15 +231,15 @@ async function createIosScenario( booted: false, }; const { tempRoot, appPath } = createDemoIosApp('agent-device-install-ledger-ios-'); - const commandOverride = createInstallLedgerHostCommandOverride(ledger); - const daemon = await withCommandExecutorOverride( - commandOverride, - async () => - await createProviderScenarioHarness({ - deviceInventoryProvider: async () => [device], - appleToolProvider: () => createInstallLedgerAppleToolProvider(route, device.id, ledger), - }), - ); + vi.spyOn(hostCommands, 'runCmd').mockImplementation(async (command, args, options = {}) => { + assert.equal(command, 'open'); + ledger.record(command, args, options.timeoutMs); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [device], + appleToolProvider: () => createInstallLedgerAppleToolProvider(route, device.id, ledger), + }); return { appPath, daemon, @@ -282,48 +261,41 @@ function createInstallLedgerAppleToolProvider( let simulatorBooted = false; return createLocalAppleToolProvider({ whichCommand: async () => true, - runCommand: async (cmd, args, options) => { - assert.equal(cmd, 'plutil'); - ledger.record(cmd, args, options?.timeoutMs); - // Conversion and extraction can both fail before the XML reader resolves the identity. - return { stdout: '', stderr: '', exitCode: 1 }; - }, - simctl: { - run: async (args, options) => { - ledger.record('simctl', args, options?.timeoutMs); - if (args.includes('list')) { - return { - stdout: JSON.stringify({ - devices: { - 'iOS 18.5': [{ udid: deviceId, state: simulatorBooted ? 'Booted' : 'Shutdown' }], - }, - }), - stderr: '', - exitCode: 0, - }; - } - if (args.includes('boot')) { - simulatorBooted = true; + runCommand: async (command, argv, options) => { + const tool = command === 'xcrun' ? argv[0] : command; + const args = command === 'xcrun' ? argv.slice(1) : argv; + assert.ok(tool); + ledger.record(tool, args, options?.timeoutMs); + switch (tool) { + case 'plutil': + // Conversion and extraction can both fail before the XML reader resolves the identity. + return { ...ok, exitCode: 1 }; + case 'simctl': + if (args.includes('list')) { + return { + ...ok, + stdout: JSON.stringify({ + devices: { + 'iOS 18.5': [{ udid: deviceId, state: simulatorBooted ? 'Booted' : 'Shutdown' }], + }, + }), + }; + } + if (args.includes('boot')) simulatorBooted = true; + else + assert.ok( + args.includes('bootstatus') || args.includes('install') || args.includes('uninstall'), + args.join(' '), + ); return ok; - } - assert.ok( - args.includes('bootstatus') || args.includes('install') || args.includes('uninstall'), - args.join(' '), - ); - return ok; - }, - }, - devicectl: { - run: async (args, options) => { - ledger.record('devicectl', args, options?.timeoutMs); - if (args.includes('details')) { - writeCoreDeviceDetailsJson(args); + case 'devicectl': + assert.equal(route, 'iOS physical device'); + if (args.includes('details')) writeCoreDeviceDetailsJson(args); + else assert.ok(args.includes('install') || args.includes('uninstall'), args.join(' ')); return ok; - } - assert.equal(route, 'iOS physical device'); - assert.ok(args.includes('install') || args.includes('uninstall'), args.join(' ')); - return ok; - }, + default: + throw new Error(`Unscripted Apple install tool: ${command} ${argv.join(' ')}`); + } }, }); } @@ -446,14 +418,6 @@ function isAndroidRoute(route: InstallRoute): route is Extract { - assert.equal(command, 'open'); - ledger.record(command, args, options.timeoutMs); - return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); - }; -} - function assertLedgerFitsEnvelope( route: string, command: InstallCommand, @@ -501,7 +465,7 @@ function assertInstallLedgerPhaseCoverage( } for (const phase of expected) { assert.ok( - ledger.some((call) => call.tool === phase || call.args.some((arg) => arg.includes(phase))), + ledger.some((call) => call.tool === phase || call.args.includes(phase)), `${route} ${command}: missing ${phase} phase. Ledger: ${JSON.stringify(ledger)}`, ); } From 7bf60bc85af22a60c9270b0d96f367a5aacd054a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 12:35:05 +0100 Subject: [PATCH 11/18] refactor(tests): flatten Apple tool command handling --- .../install-timeout-ledger.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 1fa21247e4..3d18ecacaf 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -281,17 +281,22 @@ function createInstallLedgerAppleToolProvider( }), }; } - if (args.includes('boot')) simulatorBooted = true; - else - assert.ok( - args.includes('bootstatus') || args.includes('install') || args.includes('uninstall'), - args.join(' '), - ); + if (args.includes('boot')) { + simulatorBooted = true; + return ok; + } + assert.ok( + ['bootstatus', 'install', 'uninstall'].some((action) => args.includes(action)), + args.join(' '), + ); return ok; case 'devicectl': assert.equal(route, 'iOS physical device'); - if (args.includes('details')) writeCoreDeviceDetailsJson(args); - else assert.ok(args.includes('install') || args.includes('uninstall'), args.join(' ')); + if (args.includes('details')) { + writeCoreDeviceDetailsJson(args); + return ok; + } + assert.ok(args.includes('install') || args.includes('uninstall'), args.join(' ')); return ok; default: throw new Error(`Unscripted Apple install tool: ${command} ${argv.join(' ')}`); From e0e253787226c692d51bc4cb612a6bd56e209f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 13:43:29 +0100 Subject: [PATCH 12/18] fix(deployment): adopt ready source bindings and bound push --- .../install-timeout-ledger-2026-10-30.md | 2 +- .../handlers/__tests__/install-source.test.ts | 53 +++++++++++++++++-- .../handlers/session-app-source-deployment.ts | 16 ++---- .../src/deployment/native.test.ts | 2 +- .../platform-android/src/deployment/native.ts | 10 +++- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/docs/evidence/install-timeout-ledger-2026-10-30.md b/docs/evidence/install-timeout-ledger-2026-10-30.md index 28807fdd4c..f4acbe8bc7 100644 --- a/docs/evidence/install-timeout-ledger-2026-10-30.md +++ b/docs/evidence/install-timeout-ledger-2026-10-30.md @@ -32,7 +32,7 @@ All rows exited with code 0 and returned success. Timeouts are in milliseconds; At `9eea80f2a583e7b6b05d187114922506ee1fa3d0`, the same focused provider suite ran in a disposable checkout: - Unmodified head: 19 passed. -- Restore the PR’s 330s/420s envelopes: 9 failed, 10 passed; failures report the missing daemon-result margin. +- Apply a reduced 330s/420s envelope mutation (the PR ships 480s/600s): 9 failed, 10 passed; failures report the missing daemon-result margin. - Raise the real Android install/bundletool bound from 120s to 200s: 4 failed, 15 passed; the ledger captures the changed production call bounds. Command: `pnpm exec vitest run --project provider-integration test/integration/provider-scenarios/install-timeout-ledger.test.ts --reporter verbose`. The disposable checkout was removed. diff --git a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts index c976c09cb6..248fa0f5d3 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -43,6 +43,7 @@ import { handleInstallFromSourceDeploymentCommand } from '../session-app-source- type SourceRuntimeOptions = Readonly<{ materializationAvailable?: boolean; readinessAvailable?: boolean; + readyDevice?: DeviceInfo; providerMode?: RuntimeFacts['device']['providerMode']; }>; @@ -71,7 +72,7 @@ function makeRequest( }; } -test('install_source materializes and deploys through one admitted runtime binding', async () => { +test('install_source materializes and deploys through the admitted ready runtime', async () => { const store = makeStore(); const session = makeSession({ platform: 'android', @@ -111,7 +112,7 @@ test('install_source materializes and deploys through one admitted runtime bindi }, }); expect(runtime.inspectFacts).toHaveBeenCalledOnce(); - expect(runtime.bindCalls).toHaveBeenCalledOnce(); + expect(runtime.bindCalls).toHaveBeenCalledTimes(2); expect(runtime.bindCalls.mock.calls[0]?.[1]).toMatchObject({ required: ['ensureReady', 'materializeAppSource', 'deployMaterializedApp'], }); @@ -120,6 +121,50 @@ test('install_source materializes and deploys through one admitted runtime bindi expect(deploy).toHaveBeenCalledOnce(); }); +test.each(['emulator-5554', 'emulator-5556'])( + 'install_source adopts readiness metadata before materialization and deployment (%s)', + async (readyId) => { + const store = makeStore(); + const session = makeSession({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: false, + }); + store.publish(session.name, session); + const readyDevice = { ...session.device, id: readyId, booted: true }; + const staleOperation = async (): Promise => { + throw new AppError('COMMAND_FAILED', 'Deployment used the unready binding'); + }; + const initial = createSourceRuntime(session.device, staleOperation, staleOperation, { + readyDevice, + }); + const cleanup = vi.fn(async () => {}); + const ready = createSourceRuntime( + readyDevice, + async () => ({ installablePath: '/tmp/materialized/app.apk', cleanup }), + async () => ({ packageName: 'com.example.app' }), + ); + const bindDevice: BindDeviceRuntime = async (device, use) => + await (device.booted ? ready : initial).bindDevice(device, use); + + const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), + req: makeRequest({ kind: 'path', path: '/tmp/app.apk' }), + sessionName: session.name, + sessionStore: store, + inspectFacts: initial.inspectFacts, + bindDevice, + }); + + expect(response).toMatchObject({ ok: true, data: { packageName: 'com.example.app' } }); + expect(initial.ensureReady).toHaveBeenCalledOnce(); + expect(ready.ensureReady).not.toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalledOnce(); + }, +); + test('install_source rejects unavailable facts before binding or materializing', async () => { const store = makeStore(); const session = makeSession({ @@ -244,7 +289,7 @@ test('install_source cleans materialized paths when deployment fails after admis expect(response).toMatchObject({ ok: false, error: { code: 'COMMAND_FAILED' } }); expect(runtime.inspectFacts).toHaveBeenCalledOnce(); - expect(runtime.bindCalls).toHaveBeenCalledOnce(); + expect(runtime.bindCalls).toHaveBeenCalledTimes(2); expect(runtime.ensureReady).toHaveBeenCalledOnce(); expect(cleanup).toHaveBeenCalledOnce(); }); @@ -399,7 +444,7 @@ function createSourceRuntime( options: SourceRuntimeOptions = {}, ) { const facts = sourceRuntimeFacts(device, options); - const ensureReady = vi.fn(async () => ({ ...device, booted: true })); + const ensureReady = vi.fn(async () => options.readyDevice ?? { ...device, booted: true }); const binding = sourceRuntimeBinding({ device, facts, diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index 23928be9fd..43e0694dd4 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -85,18 +85,10 @@ export async function handleInstallFromSourceDeploymentCommand(params: { ); if (unsupported) return unsupported; - let runtime = await requireRuntimeBinding(params.bindDevice)( - device, - readyMaterializeAndDeployAppUse, - ); - const ready = await runtime.operations.ensureReady({}); - if (ready.id !== device.id) { - runtime = await requireRuntimeBinding(params.bindDevice)( - ready, - readyMaterializeAndDeployAppUse, - ); - } - device = ready; + const bindDevice = requireRuntimeBinding(params.bindDevice); + const readiness = await bindDevice(device, readyMaterializeAndDeployAppUse); + device = await readiness.operations.ensureReady({}); + const runtime = await bindDevice(device, readyMaterializeAndDeployAppUse); materialized = await runtime.operations.materializeAppSource({ source: resolvedSource.source }); retained = await retainIfRequested({ artifact: materialized, diff --git a/packages/platform-android/src/deployment/native.test.ts b/packages/platform-android/src/deployment/native.test.ts index f5b65284b3..f45ecac2be 100644 --- a/packages/platform-android/src/deployment/native.test.ts +++ b/packages/platform-android/src/deployment/native.test.ts @@ -136,7 +136,7 @@ test('preserves missing-package uninstall and typed push extras', async () => { expect(run).toHaveBeenLastCalledWith( expect.objectContaining({ args: expect.arrayContaining(['--es', 'title', 'Hello', '--ez', 'enabled', 'true']), - timeoutMs: undefined, + timeoutMs: 15_000, }), signal, ); diff --git a/packages/platform-android/src/deployment/native.ts b/packages/platform-android/src/deployment/native.ts index 4576407030..17d77cbf76 100644 --- a/packages/platform-android/src/deployment/native.ts +++ b/packages/platform-android/src/deployment/native.ts @@ -77,7 +77,13 @@ export async function pushAndroidNotification( appendBroadcastExtra(words, key, value); extrasCount += 1; } - const result = await runAdb(host, device, deviceShellArgv('adb', 'shell', words), signal); + const result = await runAdb( + host, + device, + deviceShellArgv('adb', 'shell', words), + signal, + ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, + ); assertCommandSuccess(result, 'adb push broadcast failed'); return { action, extrasCount }; } @@ -210,7 +216,7 @@ async function runAdb( device: DeviceInfo, args: readonly string[], signal: AbortSignal, - timeoutMs?: number, + timeoutMs: number, ) { return await host.androidTools.runAdb(device, args, { timeoutMs, allowFailure: true }, signal); } From 2e55c221f47dab9dc0090dc6fe2558a2c5c9c2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 13:46:33 +0100 Subject: [PATCH 13/18] fix(deployment): include stopped Android targets in install selection --- .../src/sessions/handlers/session-app-deployment.ts | 1 + .../sessions/handlers/session-app-source-deployment.ts | 8 +++++++- .../provider-scenarios/install-timeout-ledger.test.ts | 8 +++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index 443ecab52a..b2ebe1ffb1 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -87,6 +87,7 @@ export async function handleAppDeploymentCommand(params: { session, flags, env: sessionStore.host.env, + androidAvdSelection: 'include-stopped', platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index 43e0694dd4..ce871f788e 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -163,7 +163,13 @@ async function resolveInstallDevice( if (session) { return session.device; } - return await resolveCommandDevice({ session, flags, env, platformServices }); + return await resolveCommandDevice({ + session, + flags, + env, + androidAvdSelection: 'include-stopped', + platformServices, + }); } function normalizePlatform( diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts index 3d18ecacaf..be9fc85606 100644 --- a/test/integration/provider-scenarios/install-timeout-ledger.test.ts +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -4,6 +4,7 @@ import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/regi import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; import * as hostCommands from '@agent-device/host-kit/command'; import { mkdtempForTestSync } from '@agent-device/test-kit'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import * as timers from 'node:timers/promises'; import { pack } from 'tar-stream'; import { @@ -388,7 +389,12 @@ async function createAndroidScenario( return { ...ok, exitCode: 1 }; } }); - const inventory = async () => [launched ? { ...device, id: 'emulator-5554' } : device]; + const inventory = async (request: DeviceInventoryRequest = {}) => + launched + ? [{ ...device, id: 'emulator-5554' }] + : request.androidAvdSelection === 'running-only' + ? [] + : [device]; vi.spyOn(inventoryContext, 'listLocalDeviceInventory').mockImplementation(inventory); const daemon = await createProviderScenarioHarness({ deviceInventoryProvider: inventory, From 0c55b797e6ed12a5dc9e3359adc942a9b2a73560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 13:49:44 +0100 Subject: [PATCH 14/18] test(deployment): record stopped AVD and regression evidence --- .../install-timeout-ledger-2026-10-30.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/evidence/install-timeout-ledger-2026-10-30.md b/docs/evidence/install-timeout-ledger-2026-10-30.md index f4acbe8bc7..66bc879dac 100644 --- a/docs/evidence/install-timeout-ledger-2026-10-30.md +++ b/docs/evidence/install-timeout-ledger-2026-10-30.md @@ -27,6 +27,30 @@ All rows exited with code 0 and returned success. Timeouts are in milliseconds; | ios-simulator | source-path | `edc59a0093f390e8` | plutil=15000, plutil=15000, plutil=15000, xcrun=120000 | | ios-simulator | source-url | `fe50368be865a97a` | plutil=15000, plutil=15000, plutil=15000, xcrun=120000 | +## Review follow-up: stopped AVD and current bindings + +Live source SHA: `7eac98d6f8257381aeca4857dd8231640841b68c`. A fresh isolated daemon ran the built plain CLI with `AGENT_DEVICE_EXEC_TRACE=true`. Android commands used `--platform android --device ReactNative_API_35B` without `--serial`. The AVD was shut down before each cold request; URL source installation also started with the fixture uninstalled. + +```sh +agent-device install ./proof.apk --platform android --device ReactNative_API_35B +agent-device reinstall com.callstack.installtimeoutproof ./proof.apk --platform android --device ReactNative_API_35B +agent-device install-from-source https:///proof.apk --platform android --device ReactNative_API_35B +``` + +All five requests exited 0 and returned success. Native exec traces record each install bound. A temporary Node spawn observer recorded unmodified subprocess arguments and PIDs; those PIDs join install arguments to the request trace. Emulator launches are counted within each request's journal interval. No providers or subprocess results were faked. + +| Request | Operation | Emulator launches | Readiness `sys.boot_completed` probes | Install target | Install timeout | +| --- | --- | --- | --- | --- | --- | +| `a280e24cee77a8b3` | cold-install | 1 | 7 | `emulator-5554` | 120000 ms | +| `23813cab42ec23ee` | cold-reinstall | 1 | 1 | `emulator-5554` | 120000 ms | +| `016748f2e8388d9e` | cold-source | 1 | 1 | `emulator-5554` | 120000 ms | +| `128e21a86874a15b` | warm-install | 0 | 0 | `emulator-5554` | 120000 ms | +| `7405a049fab27219` | ios-install | 0 | 0 | `BC54AC11-CD86-4C10-8C4E-5101C773FFEB` | 120000 ms | + +Each cold request ran exactly one `emulator -avd ReactNative_API_35B`, then readiness probes, then `adb -s emulator-5554 install -r `. There was no second boot. The iOS request ran `xcrun simctl install BC54AC11-CD86-4C10-8C4E-5101C773FFEB `. Both warm requests ran after the binding-cache changes. + +The first plain-CLI attempt at `c1a190fd85` returned `DEVICE_NOT_FOUND`: install selection omitted stopped AVDs. The route now requests `include-stopped`. The ledger inventory fake honors `running-only` instead of returning stopped AVDs unconditionally. + ## Ledger regression proof At `9eea80f2a583e7b6b05d187114922506ee1fa3d0`, the same focused provider suite ran in a disposable checkout: @@ -37,10 +61,12 @@ At `9eea80f2a583e7b6b05d187114922506ee1fa3d0`, the same focused provider suite r Command: `pnpm exec vitest run --project provider-integration test/integration/provider-scenarios/install-timeout-ledger.test.ts --reporter verbose`. The disposable checkout was removed. +At `c1a190fd85`, isolated regression cases for source readiness and push pass (3 cases); restoring the prior production code gives 2 failures and 1 pass. The same-ID source case fails on stale materialization; the push case detects the absent 15s bound. At `7eac98d6f8`, the strengthened inventory ledger passes all 23 cases; removing the stopped-AVD selection fix gives 11 failures and 12 passes. Both experiments used disposable checkouts, which were removed. + The final ledger also covers a running emulator that becomes ready without changing its serial. Request binding caches distinguish the old and fresh boot observations. The ledger covers shutdown Android AVD discovery, boot polling, the changed serial, foreign device claims, metadata inspection, uninstall, APK install, both bundletool phases, simulator boot, CoreDevice readiness, and URL download. Archive extraction remains bounded by size, not wall-clock time. Provider-owned install implementations remain responsible for honoring the supplied timeout. ## Limits -No physical iOS device was available; its route has provider integration coverage. A separate live shutdown-AVD attempt stopped at selector validation: the device-panel CLI pins a running serial, does not select a stopped AVD by serial, and rejects clearing it. The 12 successful matrix cases used already booted targets. +No physical iOS device was available; its route has provider integration coverage. The original 12-case matrix used already booted targets; the follow-up above verifies the stopped AVD with the plain CLI. The physical iOS route remains unverified on hardware. No app session was opened. The temporary daemon, file server, and HTTPS tunnel are stopped after validation. From 7697e56d402fca7ea1f260e83b4f9d8dc86dd55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 14:02:36 +0100 Subject: [PATCH 15/18] fix(deployment): share install selection policy across request phases --- .../src/daemon-command-descriptor.ts | 2 ++ .../src/descriptors/session-operations.ts | 14 +++++++++++++- .../daemon/src/core/daemon-command-registry.ts | 6 ++++++ packages/daemon/src/server/request-binding.ts | 6 +++++- .../daemon/src/server/request-execution-scope.ts | 9 ++++++++- .../server/request-platform-provider-context.ts | 7 +++++-- .../sessions/handlers/session-app-deployment.ts | 3 ++- .../handlers/session-app-source-deployment.ts | 3 ++- 8 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/command-registry/src/daemon-command-descriptor.ts b/packages/command-registry/src/daemon-command-descriptor.ts index 4eda1b9662..b212e6cab3 100644 --- a/packages/command-registry/src/daemon-command-descriptor.ts +++ b/packages/command-registry/src/daemon-command-descriptor.ts @@ -1,3 +1,4 @@ +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import type { DispatchedCommand } from '@agent-device/contracts/command'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; @@ -45,6 +46,7 @@ export type DaemonCommandDescriptor = { appLease?: 'allowed'; sessionExecutionLockExempt?: boolean; selectorValidationExempt?: boolean; + androidAvdSelection?: DeviceInventoryRequest['androidAvdSelection']; replayScopedAction?: boolean; allowInvalidRecording?: boolean; /** diff --git a/packages/command-registry/src/descriptors/session-operations.ts b/packages/command-registry/src/descriptors/session-operations.ts index 2bf9053dc6..a273e1c564 100644 --- a/packages/command-registry/src/descriptors/session-operations.ts +++ b/packages/command-registry/src/descriptors/session-operations.ts @@ -255,10 +255,18 @@ export const SESSION_OPERATION_COMMAND_DESCRIPTORS = [ { name: 'install', ...DEPLOY_APP_COMMAND_DESCRIPTOR, + daemon: { + ...DEPLOY_APP_COMMAND_DESCRIPTOR.daemon, + androidAvdSelection: 'include-stopped', + }, }, { name: 'reinstall', ...DEPLOY_APP_COMMAND_DESCRIPTOR, + daemon: { + ...DEPLOY_APP_COMMAND_DESCRIPTOR.daemon, + androidAvdSelection: 'include-stopped', + }, }, { name: 'uninstall', @@ -283,7 +291,11 @@ export const SESSION_OPERATION_COMMAND_DESCRIPTORS = [ }, recordsSessionAction: true, recordingEffect: 'mutates-app', - daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, + daemon: { + route: 'session', + refFrameEffect: 'may-invalidate', + androidAvdSelection: 'include-stopped', + }, platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, timeoutPolicy: { ...INSTALL_TIMEOUT_POLICY, envelopeMs: INSTALL_SOURCE_REQUEST_TIMEOUT_MS }, batchable: false, diff --git a/packages/daemon/src/core/daemon-command-registry.ts b/packages/daemon/src/core/daemon-command-registry.ts index 9a91a01f05..279acd28a5 100644 --- a/packages/daemon/src/core/daemon-command-registry.ts +++ b/packages/daemon/src/core/daemon-command-registry.ts @@ -70,6 +70,12 @@ export function isAppLeaseAllowed(command: string): boolean { return getDaemonCommandDescriptor(command)?.appLease === 'allowed'; } +export function resolveCommandAndroidAvdSelection( + command: string, +): DaemonCommandDescriptor['androidAvdSelection'] { + return getDaemonCommandDescriptor(command)?.androidAvdSelection; +} + export function shouldValidateSessionSelector(command: string): boolean { return getDaemonCommandDescriptor(command)?.selectorValidationExempt !== true; } diff --git a/packages/daemon/src/server/request-binding.ts b/packages/daemon/src/server/request-binding.ts index d46dd1670a..9414549490 100644 --- a/packages/daemon/src/server/request-binding.ts +++ b/packages/daemon/src/server/request-binding.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../core/daemon-command-registry.ts'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { @@ -145,7 +146,10 @@ async function resolveReplayDeviceLock(req: DaemonRequest, env: EnvMap) { function resolveExplicitDeviceLock(req: DaemonRequest, env: EnvMap) { return hasDeviceSelectionInput(req.flags) - ? { flags: req.flags ?? {}, options: { env } } + ? { + flags: req.flags ?? {}, + options: { env, androidAvdSelection: resolveCommandAndroidAvdSelection(req.command) }, + } : undefined; } diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index 7a13f52c40..06a8e88bfa 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../core/daemon-command-registry.ts'; import { restrictRemoteHostInputs } from './remote-host-input-policy.ts'; import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -152,7 +153,13 @@ async function resolveBoundedClaimDevice( } if (req.command === 'open') return await resolveFreshBoundedOpenDevice(req, env); if (req.command === 'install_source' && session) return session.device; - const params = { session, flags: req.flags, env, platformServices }; + const params = { + session, + flags: req.flags, + env, + platformServices, + androidAvdSelection: resolveCommandAndroidAvdSelection(req.command), + }; if (req.command === 'boot') return (await resolveBootCommandSelection(params)).device; const resolve = req.command === 'shutdown' ? resolvePowerCommandDevice : resolveCommandDevice; return await resolve(params); diff --git a/packages/daemon/src/server/request-platform-provider-context.ts b/packages/daemon/src/server/request-platform-provider-context.ts index 6c3988491c..e7e0df9cc4 100644 --- a/packages/daemon/src/server/request-platform-provider-context.ts +++ b/packages/daemon/src/server/request-platform-provider-context.ts @@ -6,7 +6,10 @@ import { hasExplicitDeviceSelector, } from '../core/device/device-selector-intent.ts'; import { buildOpenTargetDeviceResolutionOptions } from '@agent-device/device-selection/open-target'; -import { resolveProviderDeviceResolutionIntent } from '../core/daemon-command-registry.ts'; +import { + resolveCommandAndroidAvdSelection, + resolveProviderDeviceResolutionIntent, +} from '../core/daemon-command-registry.ts'; import type { DaemonRequest } from '../core/daemon-request.ts'; import type { SessionState } from '../core/session-state.ts'; @@ -82,6 +85,6 @@ async function resolveProviderTargetDevice( const options = req.command === 'open' ? buildOpenTargetDeviceResolutionOptions(req.positionals?.[0], env, req.flags?.surface) - : undefined; + : { env, androidAvdSelection: resolveCommandAndroidAvdSelection(req.command) }; return await resolveTargetDevice(req.flags ?? {}, { ...options, env }); } diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index b2ebe1ffb1..b17c103e2a 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../../core/daemon-command-registry.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; @@ -87,7 +88,7 @@ export async function handleAppDeploymentCommand(params: { session, flags, env: sessionStore.host.env, - androidAvdSelection: 'include-stopped', + androidAvdSelection: resolveCommandAndroidAvdSelection(command), platformServices: params.platformServices, }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index ce871f788e..c4737148d0 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../../core/daemon-command-registry.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { EnvMap } from '@agent-device/kernel/source-value'; import type { DaemonPlatformServices } from '../../core/platform-services.ts'; @@ -167,7 +168,7 @@ async function resolveInstallDevice( session, flags, env, - androidAvdSelection: 'include-stopped', + androidAvdSelection: resolveCommandAndroidAvdSelection('install_source'), platformServices, }); } From 13a6090b5cbf5863cb86229298799ff3df13c69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 14:05:49 +0100 Subject: [PATCH 16/18] test(deployment): record shared selection verification --- .../install-timeout-ledger-2026-10-30.md | 18 +++++++++--------- .../src/server/request-execution-scope.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/evidence/install-timeout-ledger-2026-10-30.md b/docs/evidence/install-timeout-ledger-2026-10-30.md index 66bc879dac..7e48f136a6 100644 --- a/docs/evidence/install-timeout-ledger-2026-10-30.md +++ b/docs/evidence/install-timeout-ledger-2026-10-30.md @@ -29,7 +29,7 @@ All rows exited with code 0 and returned success. Timeouts are in milliseconds; ## Review follow-up: stopped AVD and current bindings -Live source SHA: `7eac98d6f8257381aeca4857dd8231640841b68c`. A fresh isolated daemon ran the built plain CLI with `AGENT_DEVICE_EXEC_TRACE=true`. Android commands used `--platform android --device ReactNative_API_35B` without `--serial`. The AVD was shut down before each cold request; URL source installation also started with the fixture uninstalled. +Live source SHA: `49ba34bdb29e008754a5ec61b4524c99b2082c6b`. A fresh isolated daemon ran the built plain CLI with `AGENT_DEVICE_EXEC_TRACE=true`. Android commands used `--platform android --device ReactNative_API_35B` without `--serial`. The AVD was shut down before each cold request; URL source installation also started with the fixture uninstalled. ```sh agent-device install ./proof.apk --platform android --device ReactNative_API_35B @@ -41,15 +41,15 @@ All five requests exited 0 and returned success. Native exec traces record each | Request | Operation | Emulator launches | Readiness `sys.boot_completed` probes | Install target | Install timeout | | --- | --- | --- | --- | --- | --- | -| `a280e24cee77a8b3` | cold-install | 1 | 7 | `emulator-5554` | 120000 ms | -| `23813cab42ec23ee` | cold-reinstall | 1 | 1 | `emulator-5554` | 120000 ms | -| `016748f2e8388d9e` | cold-source | 1 | 1 | `emulator-5554` | 120000 ms | -| `128e21a86874a15b` | warm-install | 0 | 0 | `emulator-5554` | 120000 ms | -| `7405a049fab27219` | ios-install | 0 | 0 | `BC54AC11-CD86-4C10-8C4E-5101C773FFEB` | 120000 ms | +| `795f69f4007e264c` | cold-install | 1 | 1 | `emulator-5554` | 120000 ms | +| `0a7b6d7f9a7b9707` | cold-reinstall | 1 | 1 | `emulator-5554` | 120000 ms | +| `40485b4773399c39` | cold-source | 1 | 1 | `emulator-5554` | 120000 ms | +| `5b86f1caf73508ae` | warm-install | 0 | 0 | `emulator-5554` | 120000 ms | +| `1138797a7845df53` | ios-install | 0 | 0 | `BC54AC11-CD86-4C10-8C4E-5101C773FFEB` | 120000 ms | Each cold request ran exactly one `emulator -avd ReactNative_API_35B`, then readiness probes, then `adb -s emulator-5554 install -r `. There was no second boot. The iOS request ran `xcrun simctl install BC54AC11-CD86-4C10-8C4E-5101C773FFEB `. Both warm requests ran after the binding-cache changes. -The first plain-CLI attempt at `c1a190fd85` returned `DEVICE_NOT_FOUND`: install selection omitted stopped AVDs. The route now requests `include-stopped`. The ledger inventory fake honors `running-only` instead of returning stopped AVDs unconditionally. +The first plain-CLI attempt at `c1a190fd85` returned `DEVICE_NOT_FOUND`: install selection omitted stopped AVDs. The command declaration now owns `include-stopped`; advisory locks, provider context, claim admission, and handlers share that policy. This preserves one cached inventory selection per request, including provider deployments. The ledger inventory fake honors `running-only` instead of returning stopped AVDs unconditionally. ## Ledger regression proof @@ -61,12 +61,12 @@ At `9eea80f2a583e7b6b05d187114922506ee1fa3d0`, the same focused provider suite r Command: `pnpm exec vitest run --project provider-integration test/integration/provider-scenarios/install-timeout-ledger.test.ts --reporter verbose`. The disposable checkout was removed. -At `c1a190fd85`, isolated regression cases for source readiness and push pass (3 cases); restoring the prior production code gives 2 failures and 1 pass. The same-ID source case fails on stale materialization; the push case detects the absent 15s bound. At `7eac98d6f8`, the strengthened inventory ledger passes all 23 cases; removing the stopped-AVD selection fix gives 11 failures and 12 passes. Both experiments used disposable checkouts, which were removed. +At `c1a190fd85`, isolated regression cases for source readiness and push pass (3 cases); restoring the prior production code gives 2 failures and 1 pass. The same-ID source case fails on stale materialization; the push case detects the absent 15s bound. At `7eac98d6f8`, the strengthened inventory ledger passes all 23 cases; removing the stopped-AVD selection fix gives 11 failures and 12 passes. Both experiments ran in a disposable checkout, which was removed. The final ledger also covers a running emulator that becomes ready without changing its serial. Request binding caches distinguish the old and fresh boot observations. The ledger covers shutdown Android AVD discovery, boot polling, the changed serial, foreign device claims, metadata inspection, uninstall, APK install, both bundletool phases, simulator boot, CoreDevice readiness, and URL download. Archive extraction remains bounded by size, not wall-clock time. Provider-owned install implementations remain responsible for honoring the supplied timeout. ## Limits -No physical iOS device was available; its route has provider integration coverage. The original 12-case matrix used already booted targets; the follow-up above verifies the stopped AVD with the plain CLI. The physical iOS route remains unverified on hardware. +No physical iOS device was available; its route has provider integration coverage. The original 12-case matrix used already booted targets; the follow-up above verifies the stopped AVD with the plain CLI. No app session was opened. The temporary daemon, file server, and HTTPS tunnel are stopped after validation. diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index 06a8e88bfa..f7c2ceecf8 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -1,4 +1,3 @@ -import { resolveCommandAndroidAvdSelection } from '../core/daemon-command-registry.ts'; import { restrictRemoteHostInputs } from './remote-host-input-policy.ts'; import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -65,6 +64,7 @@ import { runAdmittedLeaseWork } from './request-lease-work.ts'; import { assertMacOsAppLeaseProcess } from '../resources/macos-app-lease.ts'; import { getSessionCommandKind, + resolveCommandAndroidAvdSelection, shouldBlockForInvalidRecording, shouldLockSessionExecution, shouldValidateSessionSelector, From 3597adacaf4bd4abe2276b4a9b20dea4fe591e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 14:19:27 +0100 Subject: [PATCH 17/18] test(deployment): account for ready binding projections --- .../__tests__/session-install-source-ref-frame.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts index bf982b19f3..b3b1e5b16c 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -56,7 +56,7 @@ test('install_source materialization failure preserves an active ref frame', asy expect(response).toMatchObject({ ok: false }); expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); - expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(2); expect(mockMaterializeAppSourceRuntime).toHaveBeenCalledOnce(); expect(mockDeployMaterializedAppRuntime).not.toHaveBeenCalled(); expect(refFrameState(session)).toBe('active'); @@ -77,7 +77,7 @@ test('install_source deploy attempt expires an active ref frame when the bound o expect(response).toMatchObject({ ok: false }); expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalledOnce(); - expect(mockBindDeviceRuntime).toHaveBeenCalledOnce(); + expect(mockBindDeviceRuntime).toHaveBeenCalledTimes(2); expect(mockMaterializeAppSourceRuntime).toHaveBeenCalledOnce(); expect(mockDeployMaterializedAppRuntime).toHaveBeenCalledOnce(); expect(refFrameState(session)).toBe('expired'); From 6bce433f24641a27fe51d19738e0b2b3462be5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Oct 2026 16:53:04 +0100 Subject: [PATCH 18/18] refactor(selection): declare stopped AVD scope once --- packages/command-registry/src/registry.ts | 4 ++ .../session-device-resolution.test.ts | 6 +- .../resources/session-device-resolution.ts | 22 ++----- .../request-router-selection.test.ts | 66 ++++++++++--------- .../src/server/request-execution-scope.ts | 11 +--- .../handlers/session-app-source-deployment.ts | 7 +- .../src/sessions/handlers/session-boot.ts | 2 + .../src/sessions/handlers/session-shutdown.ts | 6 +- .../session-lifecycle/internal/inventory.ts | 12 ++-- 9 files changed, 65 insertions(+), 71 deletions(-) diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 9f3826ed01..ee3faf013b 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -303,6 +303,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { + androidAvdSelection: 'include-stopped', route: 'session', refFrameEffect: 'preserve', sessionKind: 'inventory', @@ -348,6 +349,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { + androidAvdSelection: 'include-stopped', route: 'session', refFrameEffect: 'preserve', sessionKind: 'inventory', @@ -368,6 +370,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { + androidAvdSelection: 'include-stopped', route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state', @@ -387,6 +390,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ frameworkTier: 'extended', recordsSessionAction: false, daemon: { + androidAvdSelection: 'include-stopped', route: 'session', refFrameEffect: 'may-invalidate', sessionKind: 'state', diff --git a/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts b/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts index 5776a120f3..da69d3cf2c 100644 --- a/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts +++ b/packages/daemon/src/resources/__tests__/session-device-resolution.test.ts @@ -8,7 +8,6 @@ import { resolveCommandDevice, refreshSessionDeviceIfNeeded, pinSessionDeviceSelection, - resolvePowerCommandDevice, selectorTargetsSessionDevice, } from '../session-device-resolution.ts'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; @@ -117,7 +116,7 @@ test('resolveCommandDevice does not prepare a sessionless device', async () => { expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); }); -test('power command selection includes a stopped Android AVD', async () => { +test('command selection forwards stopped Android AVD scope', async () => { const stopped: DeviceInfo = { platform: 'android', id: 'emulator-5554', @@ -126,7 +125,8 @@ test('power command selection includes a stopped Android AVD', async () => { booted: false, }; mockResolveTargetDevice.mockResolvedValue(stopped); - const selected = await resolvePowerCommandDevice({ + const selected = await resolveCommandDevice({ + androidAvdSelection: 'include-stopped', session: undefined, flags: { platform: 'android', device: 'Pixel_9' }, env: process.env, diff --git a/packages/daemon/src/resources/session-device-resolution.ts b/packages/daemon/src/resources/session-device-resolution.ts index 8a7b855844..6d47831bfe 100644 --- a/packages/daemon/src/resources/session-device-resolution.ts +++ b/packages/daemon/src/resources/session-device-resolution.ts @@ -76,31 +76,19 @@ export async function resolveCommandDevice(params: { return hostApp && device.id === hostApp.hostDeviceId ? params.session!.device : device; } -type PowerCommandDeviceParams = Omit< - Parameters[0], - 'androidAvdSelection' ->; - -/** Boot and shutdown both allow a stopped Android AVD to be selected by name. */ -export async function resolvePowerCommandDevice( - params: PowerCommandDeviceParams, -): Promise { - return await resolveCommandDevice({ ...params, androidAvdSelection: 'include-stopped' }); -} - /** - * Boot's device with its selection evidence. Boot alone reads the `--pick` opt-in; shutdown shares - * the stopped-AVD scope above and keeps refusing a tie. + * Boot's device with its selection evidence. Boot alone reads the `--pick` opt-in; other + * commands keep refusing a tie. */ export async function resolveBootCommandSelection( - params: PowerCommandDeviceParams, + params: Parameters[0], ): Promise { if (params.session && !hasExplicitDeviceSelector(params.flags)) { - return resolveExistingSessionDeviceSelection(await resolvePowerCommandDevice(params)); + return resolveExistingSessionDeviceSelection(await resolveCommandDevice(params)); } return await resolveTargetDeviceSelection(params.flags ?? {}, { env: params.env, - androidAvdSelection: 'include-stopped', + androidAvdSelection: params.androidAvdSelection, boot: { pick: params.flags?.pick }, }); } diff --git a/packages/daemon/src/server/__tests__/request-router-selection.test.ts b/packages/daemon/src/server/__tests__/request-router-selection.test.ts index b0a7debcf9..67d713b8c4 100644 --- a/packages/daemon/src/server/__tests__/request-router-selection.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-selection.test.ts @@ -46,7 +46,9 @@ function selectionHandler( deviceInventoryGateways: createTestDeviceInventoryGateways({ local: async (request) => { inventoryRequests.push(request); - return devices; + return request.androidAvdSelection === 'running-only' + ? devices.filter((device) => device.platform !== 'android' || device.booted === true) + : devices; }, }), deviceRuntimeGateway: { ...lifecycleDeviceRuntimeGateway, inspectFacts }, @@ -80,33 +82,37 @@ test('fresh no-app foreground open selects iOS from mixed Android and iOS invent expect(sessionStore.get('foreground')?.device.id).toBe(ios.id); }); -test('shutdown selects a stopped Android AVD and rejects an invalid selector before facts', async () => { - const inventoryRequests: Array<{ platform?: string; androidAvdSelection?: string }> = []; - const stopped = { ...android, booted: false }; - const { handler, inspectFacts } = selectionHandler([stopped], inventoryRequests); - const selected = await handler({ - token: 'test-token', - session: 'no-session', - command: 'shutdown', - positionals: [], - flags: { platform: 'android', device: stopped.name }, - meta: { requestId: 'shutdown-stopped' }, - }); - expect( - inventoryRequests.some((request) => request.androidAvdSelection === 'include-stopped'), - ).toBe(true); - if (!selected.ok) expect(selected.error.code).not.toBe('DEVICE_NOT_FOUND'); - expect(inspectFacts).toHaveBeenCalledWith(stopped); - const factsBeforeInvalid = inspectFacts.mock.calls.length; +test.each(['boot', 'shutdown', 'capabilities', 'apps'] as const)( + '%s keeps stopped-AVD selection consistent through request admission', + async (command) => { + const inventoryRequests: Array<{ platform?: string; androidAvdSelection?: string }> = []; + const stopped = { ...android, id: android.name, booted: false }; + const { handler, inspectFacts } = selectionHandler([stopped], inventoryRequests); + const selected = await handler({ + token: 'test-token', + session: 'no-session', + command, + positionals: [], + flags: { platform: 'android', device: stopped.name }, + meta: { requestId: `${command}-stopped` }, + }); + expect( + inventoryRequests.length > 0 && + inventoryRequests.every((request) => request.androidAvdSelection === 'include-stopped'), + ).toBe(true); + if (!selected.ok) expect(selected.error.code).not.toBe('DEVICE_NOT_FOUND'); + expect(inspectFacts).toHaveBeenCalledWith(stopped); + const factsBeforeInvalid = inspectFacts.mock.calls.length; - const invalid = await handler({ - token: 'test-token', - session: 'no-session', - command: 'shutdown', - positionals: [], - flags: { platform: 'android', device: 'missing-avd' }, - meta: { requestId: 'shutdown-missing' }, - }); - expect(invalid).toMatchObject({ ok: false, error: { code: 'DEVICE_NOT_FOUND' } }); - expect(inspectFacts).toHaveBeenCalledTimes(factsBeforeInvalid); -}); + const invalid = await handler({ + token: 'test-token', + session: 'no-session', + command, + positionals: [], + flags: { platform: 'android', device: 'missing-avd' }, + meta: { requestId: `${command}-missing` }, + }); + expect(invalid).toMatchObject({ ok: false, error: { code: 'DEVICE_NOT_FOUND' } }); + expect(inspectFacts).toHaveBeenCalledTimes(factsBeforeInvalid); + }, +); diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index f7c2ceecf8..86f758e327 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -138,12 +138,8 @@ async function resolveBoundedClaimDevice( env: EnvMap, platformServices: DaemonPlatformServices, ): Promise { - const { - resolveBootCommandSelection, - resolveCommandDevice, - resolvePowerCommandDevice, - requireSessionOrExplicitSelector, - } = await import('../resources/session-device-resolution.ts'); + const { resolveBootCommandSelection, resolveCommandDevice, requireSessionOrExplicitSelector } = + await import('../resources/session-device-resolution.ts'); if (req.command !== 'open') { const guard = requireSessionOrExplicitSelector(req.command, session, req.flags); if (guard && !guard.ok) { @@ -161,8 +157,7 @@ async function resolveBoundedClaimDevice( androidAvdSelection: resolveCommandAndroidAvdSelection(req.command), }; if (req.command === 'boot') return (await resolveBootCommandSelection(params)).device; - const resolve = req.command === 'shutdown' ? resolvePowerCommandDevice : resolveCommandDevice; - return await resolve(params); + return await resolveCommandDevice(params); } // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps diff --git a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts index c4737148d0..74d31bf46f 100644 --- a/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-source-deployment.ts @@ -69,7 +69,7 @@ export async function handleInstallFromSourceDeploymentCommand(params: { const retention = resolveRetention(req); let device = await resolveInstallDevice( session, - req.flags, + req, sessionStore.host.env, params.platformServices, ); @@ -141,10 +141,11 @@ export async function handleReleaseMaterializedPathsCommand(params: { async function resolveInstallDevice( session: SessionState | undefined, - flags: DaemonRequest['flags'] | undefined, + req: DaemonRequest, env: EnvMap, platformServices: DaemonPlatformServices, ): Promise { + const flags = req.flags; const requestedPlatform = normalizePlatform(flags?.platform); if (session && requestedPlatform && !matchesPlatformSelector(session.device, requestedPlatform)) { throw new AppError( @@ -168,7 +169,7 @@ async function resolveInstallDevice( session, flags, env, - androidAvdSelection: resolveCommandAndroidAvdSelection('install_source'), + androidAvdSelection: resolveCommandAndroidAvdSelection(req.command), platformServices, }); } diff --git a/packages/daemon/src/sessions/handlers/session-boot.ts b/packages/daemon/src/sessions/handlers/session-boot.ts index 0620a5b8f4..d1dceb590c 100644 --- a/packages/daemon/src/sessions/handlers/session-boot.ts +++ b/packages/daemon/src/sessions/handlers/session-boot.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../../core/daemon-command-registry.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { resolveDeviceReadinessRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -60,6 +61,7 @@ async function resolveBootTargetSelection( session, flags, env: sessionStore.host.env, + androidAvdSelection: resolveCommandAndroidAvdSelection(req.command), platformServices, }); const { device } = selection; diff --git a/packages/daemon/src/sessions/handlers/session-shutdown.ts b/packages/daemon/src/sessions/handlers/session-shutdown.ts index 27e417ed78..c4336d706a 100644 --- a/packages/daemon/src/sessions/handlers/session-shutdown.ts +++ b/packages/daemon/src/sessions/handlers/session-shutdown.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../../core/daemon-command-registry.ts'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import type { TargetShutdownResult } from '@agent-device/contracts/device'; @@ -6,7 +7,7 @@ import { publicPlatformString } from '@agent-device/kernel/device'; import { errorResponse } from '@agent-device/kernel/contracts'; import { requireSessionOrExplicitSelector, - resolvePowerCommandDevice, + resolveCommandDevice, } from '../../resources/session-device-resolution.ts'; import { requireRuntimeBinding, @@ -43,10 +44,11 @@ export async function handleShutdownCommand( const guard = requireSessionOrExplicitSelector(params.req.command, activeSession, flags); if (guard) return guard; - const device = await resolvePowerCommandDevice({ + const device = await resolveCommandDevice({ flags, session: activeSession, env: params.sessionStore.host.env, + androidAvdSelection: resolveCommandAndroidAvdSelection(params.req.command), platformServices: params.platformServices, }); const inspectFacts = requireRuntimeFacts(params.inspectFacts); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts index 9cfeefb30d..5881f68729 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts @@ -1,3 +1,4 @@ +import { resolveCommandAndroidAvdSelection } from '../../../core/daemon-command-registry.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { resolveSessionRunnerLogPath } from '@agent-device/daemon-contracts/session-artifact-paths'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; @@ -273,10 +274,7 @@ async function capabilitiesInventoryResponse(params: { inspectFacts?: InspectDeviceRuntimeFacts; platformServices: DaemonPlatformServices; }): Promise { - const resolution = await resolveInventoryCommandDevice({ - ...params, - androidAvdSelection: 'include-stopped', - }); + const resolution = await resolveInventoryCommandDevice(params); if ('response' in resolution) return resolution.response; const { device } = resolution; const sessionOwnedAppStateAvailable = isSessionOwnedAppStateAvailable( @@ -349,7 +347,6 @@ async function handleAppsInventory(params: { req, sessionName, sessionStore, - androidAvdSelection: 'include-stopped', platformServices, }); if ('response' in resolution) return resolution.response; @@ -448,10 +445,9 @@ async function resolveInventoryCommandDevice(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; - androidAvdSelection?: 'running-only' | 'include-stopped'; platformServices: DaemonPlatformServices; }): Promise<{ device: DeviceInfo } | { response: DaemonResponse }> { - const { req, sessionName, sessionStore, androidAvdSelection, platformServices } = params; + const { req, sessionName, sessionStore, platformServices } = params; const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; const response = requireSessionOrExplicitSelector(req.command, session, flags); @@ -463,7 +459,7 @@ async function resolveInventoryCommandDevice(params: { flags, env: sessionStore.host.env, platformServices, - androidAvdSelection, + androidAvdSelection: resolveCommandAndroidAvdSelection(req.command), }), }; }