From ff9b56fde5b18f4978686172a0b878bef9e6c5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 23:50:40 +0200 Subject: [PATCH 01/21] perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner Local Simulator opens now decide how much the XCTest runner is needed from the runtime operations declared by the steps still ahead in the same batch: an observation-only plan starts no runner, an unknown plan keeps the speculative prewarm without ever awaiting it, and a plan with an interaction prepares readiness for that step. open --relaunch no longer waits for runner readiness on a Simulator and resets the runner target only when a session is already alive. The Apple find ports report not-proven instead of starting a runner on a Simulator without a live session, so wait and read-only find observe through the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged. The plan travels through the server-private internal request channel, never the wire; the Apple owner maps declared operations to a runner demand through a record complete over the runtime operation union. Refs #2198 --- CONTEXT.md | 6 + .../src/application-lifecycle-runtime.ts | 25 +++ packages/platform-apple/src/lifecycle.test.ts | 170 ++++++++++++++++++ packages/platform-apple/src/lifecycle.ts | 26 ++- .../platform-apple/src/runner-demand.test.ts | 40 +++++ packages/platform-apple/src/runner-demand.ts | 123 +++++++++++++ .../src/runtime-snapshot.test.ts | 76 +++++++- .../platform-apple/src/runtime-snapshot.ts | 19 +- .../platform-apple/src/runtime.fixtures.ts | 1 + src/core/__tests__/batch.test.ts | 29 +++ src/core/batch.ts | 53 +++--- .../__tests__/planned-operations.test.ts | 33 ++++ .../command-descriptor/planned-operations.ts | 32 ++++ src/daemon/__tests__/execution-plan.test.ts | 41 +++++ src/daemon/execution-plan.ts | 23 +++ src/daemon/handlers/session-batch.ts | 13 +- .../internal/session-open-execution.ts | 2 + src/daemon/types.ts | 7 + ...latform-runtime-apple-application-tools.ts | 5 + 19 files changed, 695 insertions(+), 29 deletions(-) create mode 100644 packages/platform-apple/src/runner-demand.test.ts create mode 100644 packages/platform-apple/src/runner-demand.ts create mode 100644 src/core/command-descriptor/__tests__/planned-operations.test.ts create mode 100644 src/core/command-descriptor/planned-operations.ts create mode 100644 src/daemon/__tests__/execution-plan.test.ts create mode 100644 src/daemon/execution-plan.ts diff --git a/CONTEXT.md b/CONTEXT.md index 85c8960f8e..fb4fb86a23 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -126,6 +126,12 @@ The daemon-side truth for route ownership and request-policy traits. Per-command classifications controlling Apple runner lifecycle and recovery behavior independently of the public command surface. +**Runner demand**: +The Apple owner's decision, per local-Simulator open, of how much the XCTest runner is known to be +needed by the steps still ahead in the same batch: `none` starts no runner, `possible` (unknown +plan) prewarms without ever awaiting readiness, `required` prepares readiness for the first +runner-dependent step. Derived from the commands' declared runtime operations, never a public flag. + **Daemon RPC protocol version**: The integer used to detect breaking compatibility across the remote daemon boundary. diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index d4790a84e3..e983ccaa76 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -66,6 +66,23 @@ export type OpenApplicationPreparationInput = Readonly<{ execution: ApplicationLifecycleExecution; }>; +/** + * The declared runtime operations of the steps known to follow this open inside the same plan + * (today: the remaining steps of a `batch`). The daemon derives it from the command descriptors' + * declared runtime uses; it is never a public flag and never crosses the wire. Absent when the + * future of the session is unknown (a standalone `open`). + */ +export type OpenApplicationPlan = Readonly<{ operations: readonly string[] }>; + +/** + * How much the platform's interaction host (the XCTest runner on iOS) is known to be needed by + * the plan that contains an open. `none`: every following step is proven observation-only, so no + * runner is started or retained. `possible`: the plan is unknown, so a speculative prewarm may run + * but observation never awaits it. `required`: a following step needs the runner, so readiness is + * prepared now and awaited by that step. + */ +export type OpenApplicationRunnerDemand = 'none' | 'possible' | 'required'; + /** The normalized public application launch, independent of daemon request shape. */ export type OpenApplicationInput = Readonly<{ target?: string; @@ -77,6 +94,7 @@ export type OpenApplicationInput = Readonly<{ hasExistingSession: boolean; relaunch: boolean; prewarmRunnerBeforeOpen: boolean; + plan?: OpenApplicationPlan; enableTestIme: boolean; stateDir: string; runtimeHints: RuntimeHintValues; @@ -92,6 +110,8 @@ export type OpenApplicationInput = Readonly<{ export type OpenApplicationTiming = Readonly<{ relaunchCloseDurationMs?: number; runtimeHintsDurationMs?: number; + /** The runner demand the platform resolved for this open, when the platform decides one. */ + runnerDemand?: OpenApplicationRunnerDemand; runnerPrewarmKind?: 'session' | 'xctestrun'; runnerPrewarmScheduled?: boolean; runnerPrewarmWaited?: boolean; @@ -273,6 +293,11 @@ export type AppleApplicationTools = Readonly<{ signal: AbortSignal, ): Promise; stopRunnerSession(deviceId: string): Promise; + /** + * Whether a runner session for this device is already alive. A starting session is not alive: + * observation paths use this to avoid awaiting runner readiness they do not need. + */ + hasLiveRunnerSession(deviceId: string): Promise; scheduleRunnerIdleStop(deviceId: string): void; prepareRunner( device: DeviceInfo, diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 7c990f78f3..1531f6c498 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -259,3 +259,173 @@ function openInput(): OpenApplicationInput { execution: {}, }; } + +const simulator: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'ios-simulator', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +function simulatorHost(overrides: { + prewarmRunnerSession?: () => Promise; + hasLiveRunnerSession?: () => Promise; + events: string[]; +}) { + const interactor = { + close: vi.fn(async () => { + overrides.events.push('close'); + }), + open: vi.fn(async () => { + overrides.events.push('open'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const prewarmRunnerSession = vi.fn( + overrides.prewarmRunnerSession ?? + (async () => { + overrides.events.push('prewarm'); + }), + ); + const notifyRunnerAppRelaunched = vi.fn(async () => { + overrides.events.push('reset'); + }); + const hasLiveRunnerSession = vi.fn(overrides.hasLiveRunnerSession ?? (async () => false)); + const host = { + ...baseHost, + clock: { ...baseHost.clock, sleep: async () => {} }, + localInteractors: { resolve: async () => interactor }, + appleApplications: { + ...baseHost.appleApplications, + prewarmRunnerSession, + notifyRunnerAppRelaunched, + hasLiveRunnerSession, + }, + } as unknown as PlatformRuntimeHost; + return { host, prewarmRunnerSession, notifyRunnerAppRelaunched, hasLiveRunnerSession }; +} + +test('a Simulator open whose plan is observation-only starts no runner and reports demand none', async () => { + const events: string[] = []; + const { host, prewarmRunnerSession, notifyRunnerAppRelaunched } = simulatorHost({ events }); + const lifecycle = bindAppleApplicationLifecycle({ + host, + device: simulator, + signal: new AbortController().signal, + }); + + const outcome = await lifecycle.openApplication({ + ...openInput(), + plan: { operations: ['captureSnapshot', 'findText', 'captureScreenshot'] }, + }); + + expect(outcome.timing.runnerDemand).toBe('none'); + expect(outcome.timing.runnerPrewarmScheduled).toBeUndefined(); + expect(prewarmRunnerSession).not.toHaveBeenCalled(); + expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); + expect(events).toEqual(['open']); +}); + +test.each([ + ['an unknown plan', undefined, 'possible'], + ['a plan that needs the runner', { operations: ['captureSnapshot', 'tapPoint'] }, 'required'], +] as const)( + 'a Simulator relaunch with %s schedules the runner prewarm without awaiting it', + async (_name, plan, expectedDemand) => { + const events: string[] = []; + let releasePrewarm = () => {}; + const { host, prewarmRunnerSession, notifyRunnerAppRelaunched } = simulatorHost({ + events, + // A prewarm that never finishes inside the open: if the open awaited runner readiness + // this test would time out instead of passing. + prewarmRunnerSession: () => + new Promise((resolve) => { + releasePrewarm = resolve; + }), + }); + const lifecycle = bindAppleApplicationLifecycle({ + host, + device: simulator, + signal: new AbortController().signal, + }); + + const opened = lifecycle.openApplication({ ...openInput(), relaunch: true, plan }); + const outcome = await Promise.race([ + opened, + new Promise<'awaited-runner-readiness'>((resolve) => + setTimeout(() => resolve('awaited-runner-readiness'), 500), + ), + ]); + releasePrewarm(); + + expect(outcome).not.toBe('awaited-runner-readiness'); + if (outcome === 'awaited-runner-readiness') return; + expect(outcome.timing.runnerDemand).toBe(expectedDemand); + expect(outcome.timing.runnerPrewarmScheduled).toBe(true); + expect(outcome.timing.runnerPrewarmWaited).toBe(false); + expect(prewarmRunnerSession).toHaveBeenCalledOnce(); + // The starting runner has no cached target, so nothing is reset and nothing is awaited. + expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); + expect(events).toEqual(['open']); + }, +); + +test('a Simulator relaunch resets the target only on a runner that is already alive', async () => { + const events: string[] = []; + const { host, notifyRunnerAppRelaunched, hasLiveRunnerSession } = simulatorHost({ + events, + hasLiveRunnerSession: async () => true, + }); + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, signal }); + + await lifecycle.openApplication({ ...openInput(), relaunch: true }); + + expect(hasLiveRunnerSession).toHaveBeenCalledWith(simulator.id); + expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(simulator, {}, signal); + expect(events).toEqual(['prewarm', 'open', 'reset']); +}); + +test('a physical iOS relaunch still awaits the runner prewarm and ignores the plan', async () => { + const events: string[] = []; + const interactor = { + close: vi.fn(async () => { + events.push('close'); + }), + open: vi.fn(async () => { + events.push('open'); + }), + } as unknown as Interactor; + const baseHost = platformRuntimeHostFixture(); + const host = { + ...baseHost, + localInteractors: { resolve: async () => interactor }, + appleApplications: { + ...baseHost.appleApplications, + prewarmRunnerSession: vi.fn(async () => { + events.push('prewarm'); + }), + notifyRunnerAppRelaunched: vi.fn(async () => { + events.push('reset'); + }), + hasLiveRunnerSession: vi.fn(async () => false), + }, + } as unknown as PlatformRuntimeHost; + const lifecycle = bindAppleApplicationLifecycle({ + host, + device, + signal: new AbortController().signal, + }); + + const outcome = await lifecycle.openApplication({ + ...openInput(), + plan: { operations: ['captureSnapshot'] }, + }); + + expect(outcome.timing.runnerDemand).toBeUndefined(); + expect(outcome.timing.runnerPrewarmWaited).toBe(true); + expect(events).toEqual(['close', 'open', 'prewarm', 'reset']); +}); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index ad03d7df9e..3b4ec7bb88 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -17,6 +17,7 @@ import { } from '@agent-device/contracts/application-lifecycle-interaction'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { ensureAppleReady } from './readiness/runtime.ts'; +import { resolveAppleSimulatorRunnerDemand } from './runner-demand.ts'; import { isApplePlatform, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -92,11 +93,18 @@ async function openAppleApplication( const timing: MutableOpenTiming = {}; const localIosSimulator = isIosSimulator(binding.device); const runner = createRunnerPrewarm(host, binding, input, timing); + // Only a local Simulator has a runner-free observation path (the host AX bridge), so only it + // consults the plan. Physical devices keep their runner lifecycle unchanged. + const runnerDemand = localIosSimulator + ? resolveAppleSimulatorRunnerDemand(input.plan) + : undefined; + if (runnerDemand) timing.runnerDemand = runnerDemand; const shouldPrewarmRunner = isIosFamily(binding.device) && input.surface === 'app' && input.positionals.length > 0 && - Boolean(input.appBundleId); + Boolean(input.appBundleId) && + runnerDemand !== 'none'; const retainRunnerForRelaunch = shouldRetainRunnerForRelaunch( binding.device, input, @@ -116,7 +124,13 @@ async function openAppleApplication( await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, input.prewarmRunnerBeforeOpen); const runnerTargetPredatesOpen = runner.wasAwaited(); await dispatchAppleOpen(binding, input, localIosSimulator, timing); - await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, input.relaunch); + // A Simulator open never waits for runner readiness: bridge observation does not need it and + // the first runner-dependent command awaits the same startup under the runner session lock. + await finishAppleRunnerPrewarm( + runner, + shouldPrewarmRunner, + input.relaunch && !localIosSimulator, + ); await notifyAppleRunnerRelaunch( host, binding, @@ -256,6 +270,14 @@ async function notifyAppleRunnerRelaunch( ) { return; } + // Only a runner that is already alive can hold a stale cached target. A starting runner has + // none, and asking it would await its startup; a fresh one re-resolves the target on first use. + if ( + localIosSimulator && + !(await host.appleApplications.hasLiveRunnerSession(binding.device.id)) + ) { + return; + } await host.appleApplications.notifyRunnerAppRelaunched( binding.device, input.execution, diff --git a/packages/platform-apple/src/runner-demand.test.ts b/packages/platform-apple/src/runner-demand.test.ts new file mode 100644 index 0000000000..7b9ad28d7f --- /dev/null +++ b/packages/platform-apple/src/runner-demand.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'vitest'; +import { resolveAppleSimulatorRunnerDemand } from './runner-demand.ts'; + +test('an unknown plan keeps the speculative prewarm', () => { + expect(resolveAppleSimulatorRunnerDemand(undefined)).toBe('possible'); +}); + +test('a plan served entirely by simctl and the AX bridge needs no runner', () => { + expect( + resolveAppleSimulatorRunnerDemand({ + operations: [ + 'captureSnapshot', + 'captureSnapshotWithoutActiveApp', + 'captureScreenshot', + 'findText', + 'findSelector', + 'closeApplication', + 'finalizeApplicationClose', + ], + }), + ).toBe('none'); +}); + +test.each([ + ['a touch', 'tapPoint'], + ['a text read', 'readTextAtPoint'], + ['custom actions', 'captureSnapshotWithCustomActions'], + ['an alert', 'readAlert'], + ['runner preparation', 'prepareAppleRunner'], +])('a plan containing %s requires the runner', (_name, operation) => { + expect(resolveAppleSimulatorRunnerDemand({ operations: ['captureSnapshot', operation] })).toBe( + 'required', + ); +}); + +test('an operation the table does not know is never proven runner-free', () => { + expect(resolveAppleSimulatorRunnerDemand({ operations: ['notARuntimeOperation'] })).toBe( + 'required', + ); +}); diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts new file mode 100644 index 0000000000..f24d4de634 --- /dev/null +++ b/packages/platform-apple/src/runner-demand.ts @@ -0,0 +1,123 @@ +import type { + OpenApplicationPlan, + OpenApplicationRunnerDemand, +} from '@agent-device/contracts/application-lifecycle-runtime'; +import type { RuntimeOperationKey } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; + +/** + * Which host the Apple runtime executes each declared runtime operation through on a local iOS + * Simulator. `runner` operations need the XCTest runner; `simulator` operations are served by + * simctl, the host AX bridge, or host-side tooling and never wait for runner readiness. + * + * Bridge-eligible snapshots keep their typed XCTest fallback, but a fallback is a recovery, not a + * plan requirement, so they classify as `simulator`. The record is complete over the runtime + * operation union by construction: a new operation refuses to compile until it is classified. + */ +type AppleSimulatorOperationHost = 'runner' | 'simulator'; + +const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< + Record, AppleSimulatorOperationHost> +> = Object.freeze({ + // Application lifecycle: simctl launch/terminate and host readiness. + resolveOpenTarget: 'simulator', + prepareApplicationOpen: 'simulator', + openApplication: 'simulator', + applyRuntimeHints: 'simulator', + clearRuntimeHints: 'simulator', + closeApplication: 'simulator', + finalizeApplicationClose: 'simulator', + prepareAppleRunner: 'runner', + configureProviderPortReverse: 'simulator', + ensureReady: 'simulator', + bootTarget: 'simulator', + bootTargetHeadless: 'simulator', + shutdownTarget: 'simulator', + // App inventory, deployment, state, logs, network, audio: simctl and host tooling. + listApps: 'simulator', + deployApp: 'simulator', + materializeAppSource: 'simulator', + deployMaterializedApp: 'simulator', + sendPushNotification: 'simulator', + appState: 'simulator', + appLogStart: 'simulator', + appLogInspect: 'simulator', + appLogDoctor: 'simulator', + appLogReattach: 'simulator', + appLogCleanup: 'simulator', + networkDump: 'simulator', + audioProbeStart: 'simulator', + audioProbeQuery: 'simulator', + audioProbeReattach: 'simulator', + audioProbeCleanup: 'simulator', + readClipboard: 'simulator', + writeClipboard: 'simulator', + setSetting: 'simulator', + // Observation: the AX bridge presents regular and raw trees; custom actions need XCTest. + captureSnapshot: 'simulator', + captureSnapshotWithoutActiveApp: 'simulator', + captureSnapshotWithCustomActions: 'runner', + captureScreenshot: 'simulator', + findText: 'simulator', + findSelector: 'simulator', + readTextAtPoint: 'runner', + // Every interaction and runner-driven capture. + tapPoint: 'runner', + tapRef: 'runner', + tapElementSelector: 'runner', + fillPoint: 'runner', + fillRef: 'runner', + longPressPoint: 'runner', + hoverPoint: 'runner', + hoverRef: 'runner', + focusPoint: 'runner', + typeText: 'runner', + scrollDirection: 'runner', + performGesturePlan: 'runner', + performMultiTouchGesturePlan: 'runner', + performTargetAuthoredDrag: 'runner', + performDirectionalFlingPlan: 'runner', + gestureViewport: 'runner', + setViewport: 'runner', + back: 'runner', + home: 'runner', + setOrientation: 'runner', + appSwitcher: 'runner', + tvRemote: 'runner', + keyboardDismiss: 'runner', + keyboardEnter: 'runner', + keyboardStatus: 'runner', + triggerAppEvent: 'runner', + readAlert: 'runner', + awaitAlert: 'runner', + acceptAlert: 'runner', + dismissAlert: 'runner', + perfFrames: 'runner', + perfMemorySample: 'runner', + perfMemorySnapshot: 'runner', + perfNativeCaptureStart: 'runner', + perfNativeCaptureReattach: 'runner', + perfNativeCaptureCleanup: 'runner', + perfProfileReport: 'runner', + screenRecordingStart: 'runner', + screenRecordingReattach: 'runner', + screenRecordingCleanup: 'runner', +}); + +/** + * The runner demand of one local-Simulator open. An unknown plan keeps today's speculative + * prewarm; a plan whose operations are all simulator-served proves no runner is needed; any + * runner-served operation makes readiness worth preparing now. + */ +export function resolveAppleSimulatorRunnerDemand( + plan: OpenApplicationPlan | undefined, +): OpenApplicationRunnerDemand { + if (plan === undefined) return 'possible'; + const operations = plan.operations as readonly string[]; + const hosts = APPLE_SIMULATOR_OPERATION_HOSTS as Readonly< + Record + >; + // An operation this table does not know is not proven simulator-served. + if (operations.some((operation) => hosts[operation] !== 'simulator')) return 'required'; + return 'none'; +} diff --git a/packages/platform-apple/src/runtime-snapshot.test.ts b/packages/platform-apple/src/runtime-snapshot.test.ts index 11400b277a..2a62030715 100644 --- a/packages/platform-apple/src/runtime-snapshot.test.ts +++ b/packages/platform-apple/src/runtime-snapshot.test.ts @@ -1,8 +1,9 @@ import { expect, test, vi } from 'vitest'; import type { ElementSelectorKey } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; -import { bindAppleFindSelectorRuntime } from './runtime-snapshot.ts'; +import { bindAppleFindSelectorRuntime, bindAppleFindTextRuntime } from './runtime-snapshot.ts'; const ios = { platform: 'apple', @@ -22,7 +23,8 @@ test('findSelector resolves the owner interactor once with request execution and ) => ({ found: true }), ); const resolve = vi.fn(async () => ({ findSelector }) as never); - const host = { ...platformRuntimeHostFixture(), localInteractors: { resolve } }; + // A live runner keeps answering natively; only an absent one defers to the canonical tree. + const host = hostWithRunner(true, resolve); const request = new AbortController(); const poll = new AbortController(); const operation = bindAppleFindSelectorRuntime(host, { device: ios, signal: request.signal }); @@ -77,3 +79,73 @@ test.each([ expect(resolve).not.toHaveBeenCalled(); }, ); + +function hostWithRunner( + alive: boolean, + resolve: PlatformRuntimeHost['localInteractors']['resolve'], +): PlatformRuntimeHost { + const base = platformRuntimeHostFixture(); + return { + ...base, + localInteractors: { resolve }, + appleApplications: { ...base.appleApplications, hasLiveRunnerSession: async () => alive }, + }; +} + +test.each([ + [ + 'findText', + (host: ReturnType, device: DeviceInfo) => + bindAppleFindTextRuntime(host, { device, signal: new AbortController().signal }).findText({ + text: 'Settings', + options: { appBundleId: 'com.example.app', surface: 'app' }, + }), + ], + [ + 'findSelector', + (host: ReturnType, device: DeviceInfo) => + bindAppleFindSelectorRuntime(host, { + device, + signal: new AbortController().signal, + }).findSelector({ + selector: { key: 'label', value: 'Settings' }, + options: { appBundleId: 'com.example.app', surface: 'app' }, + }), + ], +] as const)( + '%s on a Simulator without a live runner reports not-proven instead of starting the runner', + async (_name, run) => { + const resolve = vi.fn(async () => ({}) as never); + await expect(run(hostWithRunner(false, resolve), ios)).resolves.toEqual({ found: false }); + expect(resolve).not.toHaveBeenCalled(); + }, +); + +test('findText on a Simulator with a live runner still asks the runner', async () => { + const findText = vi.fn(async () => ({ found: true })); + const resolve = vi.fn(async () => ({ findText }) as never); + const operation = bindAppleFindTextRuntime(hostWithRunner(true, resolve), { + device: ios, + signal: new AbortController().signal, + }); + + await expect( + operation.findText({ text: 'Settings', options: { appBundleId: 'com.example.app' } }), + ).resolves.toEqual({ found: true }); + expect(resolve).toHaveBeenCalledOnce(); +}); + +test('findText on a physical iOS device resolves the runner regardless of session liveness', async () => { + const findText = vi.fn(async () => ({ found: true })); + const resolve = vi.fn(async () => ({ findText }) as never); + const device = { ...ios, kind: 'device' } as const satisfies DeviceInfo; + const operation = bindAppleFindTextRuntime(hostWithRunner(false, resolve), { + device, + signal: new AbortController().signal, + }); + + await expect( + operation.findText({ text: 'Settings', options: { appBundleId: 'com.example.app' } }), + ).resolves.toEqual({ found: true }); + expect(resolve).toHaveBeenCalledOnce(); +}); diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index f31b419db8..c98d2df73b 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -13,7 +13,7 @@ import type { PlatformRuntimeHost, PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; -import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import type { AppleSnapshotRoute } from './snapshot-route.ts'; /** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */ @@ -71,7 +71,11 @@ type SnapshotRuntimeOperation = Pick< * answer would describe the wrong surface. Reporting `false` sends the poll to the desktop * surface capture, which is the reading that matches the request. * - * Both report `found: false` — "not proven here" — never an error, so the caller's canonical tree + * - Local Simulator without a live runner: the runner's answer would cost its startup, which an + * observation never needs while the canonical tree comes from the host AX bridge. A runner that + * is already alive keeps answering. + * + * All report `found: false` — "not proven here" — never an error, so the caller's canonical tree * remains the complete path (ADR 0019 section 2). */ export function bindAppleFindTextRuntime( @@ -90,6 +94,7 @@ export function bindAppleFindTextRuntime( ? request.signal : AbortSignal.any([request.signal, input.signal]); signal.throwIfAborted(); + if (await simulatorRunnerNotLive(host, request.device)) return { found: false }; const interactor = await host.localInteractors.resolve(request.device, { ...input.execution, appBundleId, @@ -121,6 +126,7 @@ export function bindAppleFindSelectorRuntime( ? AbortSignal.any([request.signal, input.signal]) : request.signal; signal.throwIfAborted(); + if (await simulatorRunnerNotLive(host, request.device)) return { found: false }; const interactor = await host.localInteractors.resolve(request.device, { ...input.execution, appBundleId, @@ -131,3 +137,12 @@ export function bindAppleFindSelectorRuntime( }, }); } + +/** A local iOS Simulator whose runner session is not alive; see the find-runtime doc above. */ +async function simulatorRunnerNotLive( + host: Pick, + device: DeviceInfo, +): Promise { + if (!isIosFamily(device) || device.kind !== 'simulator') return false; + return !(await host.appleApplications.hasLiveRunnerSession(device.id)); +} diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index efb332a54b..a81e713f0d 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -57,6 +57,7 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { prewarmRunnerSession: async () => {}, notifyRunnerAppRelaunched: async () => {}, stopRunnerSession: async () => {}, + hasLiveRunnerSession: async () => false, scheduleRunnerIdleStop: () => {}, prepareRunner: async () => ({ runner: {}, connectMs: 0, healthCheckMs: 0 }), applyRuntimeHints: async () => {}, diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index 74614d3bb4..34d285bd6b 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -118,3 +118,32 @@ test('default batch (no responseLevel) passes meta through unchanged — byte-id await runBatch(batchRequest(['snapshot', 'find', 'get']), 'session', recordingInvoke(seen)); assert.deepEqual(seen, [undefined, undefined, undefined]); }); + +test('each step is invoked with its place in the plan and the commands still ahead of it', async () => { + const seen: Array<{ + command: string; + remaining: readonly string[]; + step: number; + total: number; + }> = []; + const response = await runBatch( + batchRequest(['open', 'snapshot', 'click']), + 'session', + async (req, context) => { + seen.push({ + command: req.command, + remaining: context.remainingCommands, + step: context.stepNumber, + total: context.totalSteps, + }); + return { ok: true, data: {} }; + }, + ); + + assert.equal(response.ok, true); + assert.deepEqual(seen, [ + { command: 'open', remaining: ['snapshot', 'click'], step: 1, total: 3 }, + { command: 'snapshot', remaining: ['click'], step: 2, total: 3 }, + { command: 'click', remaining: [], step: 3, total: 3 }, + ]); +}); diff --git a/src/core/batch.ts b/src/core/batch.ts index c66fe805c8..5b494883ad 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -34,7 +34,17 @@ export type BatchRequest = Omit & { flags?: BatchFlags | Record; }; -export type BatchInvoke = (req: BatchRequest) => Promise; +/** + * What the batch runner knows about a step's place in its plan. The daemon uses the remaining + * commands to derive platform readiness policy; it never reaches the wire. + */ +export type BatchStepContext = Readonly<{ + stepNumber: number; + totalSteps: number; + remainingCommands: readonly string[]; +}>; + +export type BatchInvoke = (req: BatchRequest, context: BatchStepContext) => Promise; export type NormalizedBatchStep = { command: string; @@ -88,14 +98,11 @@ export async function runBatch( const startedAt = Date.now(); const partialResults: BatchStepResult[] = []; for (const [index, step] of steps.entries()) { - const stepResponse = await runBatchStep( - req, - sessionName, - step, - invoke, - index + 1, - index === steps.length - 1, - ); + const stepResponse = await runBatchStep(req, sessionName, step, invoke, { + stepNumber: index + 1, + totalSteps: steps.length, + remainingCommands: steps.slice(index + 1).map((remaining) => remaining.command), + }); if (!stepResponse.ok) { return { ok: false, @@ -249,8 +256,7 @@ async function runBatchStep( sessionName: string, step: NormalizedBatchStep, invoke: BatchInvoke, - stepNumber: number, - isFinalStep: boolean, + context: BatchStepContext, ): Promise< | { ok: true; step: number; result: BatchStepResult } | { @@ -259,21 +265,26 @@ async function runBatchStep( error: DaemonError; } > { + const { stepNumber, totalSteps } = context; + const isFinalStep = stepNumber === totalSteps; const stepStartedAt = Date.now(); const stepFlags = buildBatchStepFlags(req.flags, step.flags); if (stepFlags.session === undefined) { stepFlags.session = sessionName; } - const response = await invoke({ - token: req.token, - session: sessionName, - command: step.command, - positionals: step.positionals, - input: step.input, - flags: stepFlags, - runtime: step.runtime === undefined ? req.runtime : step.runtime, - meta: batchStepMeta(req.meta, isFinalStep), - }); + const response = await invoke( + { + token: req.token, + session: sessionName, + command: step.command, + positionals: step.positionals, + input: step.input, + flags: stepFlags, + runtime: step.runtime === undefined ? req.runtime : step.runtime, + meta: batchStepMeta(req.meta, isFinalStep), + }, + context, + ); const durationMs = Date.now() - stepStartedAt; if (!response.ok) { return { ok: false, step: stepNumber, error: response.error }; diff --git a/src/core/command-descriptor/__tests__/planned-operations.test.ts b/src/core/command-descriptor/__tests__/planned-operations.test.ts new file mode 100644 index 0000000000..a568230658 --- /dev/null +++ b/src/core/command-descriptor/__tests__/planned-operations.test.ts @@ -0,0 +1,33 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { resolvePlannedRuntimeOperations } from '../planned-operations.ts'; + +test('observation commands declare only capture and selector-observation operations', () => { + const operations = resolvePlannedRuntimeOperations(['snapshot', 'wait', 'is', 'screenshot']); + assert.ok(operations); + assert.ok(operations.includes('captureSnapshot')); + assert.ok(operations.includes('findText')); + for (const operation of operations) { + assert.doesNotMatch(operation, /^(tap|fill|type|scroll|perform|hover|focus|longPress)/); + } +}); + +test('an interaction command contributes its touch operations', () => { + const operations = resolvePlannedRuntimeOperations(['snapshot', 'click']); + assert.ok(operations); + assert.ok(operations.some((operation) => /^tap/.test(operation))); +}); + +test('every declared use category counts, so a mutating find is never proven observation-only', () => { + const operations = resolvePlannedRuntimeOperations(['find']); + assert.ok(operations); + assert.ok(operations.some((operation) => /^tap|^fill|^type/.test(operation))); +}); + +test('commands without device runtime execution contribute nothing', () => { + assert.deepEqual(resolvePlannedRuntimeOperations(['devices', 'capabilities']), []); +}); + +test('an unregistered command makes the plan unproven', () => { + assert.equal(resolvePlannedRuntimeOperations(['snapshot', 'not-a-command']), undefined); +}); diff --git a/src/core/command-descriptor/planned-operations.ts b/src/core/command-descriptor/planned-operations.ts new file mode 100644 index 0000000000..6b50335868 --- /dev/null +++ b/src/core/command-descriptor/planned-operations.ts @@ -0,0 +1,32 @@ +import { commandDescriptors } from './registry.ts'; +import type { CommandDescriptor } from './types.ts'; + +const descriptorsByName = new Map( + commandDescriptors.map((descriptor) => [descriptor.name, descriptor]), +); + +/** + * The runtime operations a sequence of commands may execute, read from each command's declared + * platform execution (ADR 0019 §6). Every declared category counts — required, preferred, and + * conditional — because a plan is proven observation-only only when no step can reach an + * interaction operation. Returns `undefined` when any command is unknown to the registry: an + * unregistered step makes the plan unproven rather than silently empty. + */ +export function resolvePlannedRuntimeOperations( + commands: readonly string[], +): readonly string[] | undefined { + const operations = new Set(); + for (const command of commands) { + const descriptor = descriptorsByName.get(command); + if (!descriptor) return undefined; + const execution = descriptor.platformExecution; + if (execution.kind !== 'device-runtime') continue; + const uses = 'uses' in execution ? execution.uses : [execution.use]; + for (const use of uses) { + for (const operation of [...use.required, ...use.preferred, ...(use.conditional ?? [])]) { + operations.add(operation); + } + } + } + return Object.freeze([...operations].sort()); +} diff --git a/src/daemon/__tests__/execution-plan.test.ts b/src/daemon/__tests__/execution-plan.test.ts new file mode 100644 index 0000000000..91793aeb38 --- /dev/null +++ b/src/daemon/__tests__/execution-plan.test.ts @@ -0,0 +1,41 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { resolveOpenApplicationPlan } from '../execution-plan.ts'; +import { runBatchCommands } from '../handlers/session-batch.ts'; +import type { DaemonRequest } from '../types.ts'; + +test('an open that ends its batch has an unknown future', () => { + assert.equal(resolveOpenApplicationPlan(undefined), undefined); + assert.equal(resolveOpenApplicationPlan({ remainingCommands: [] }), undefined); +}); + +test('remaining observation steps resolve to their declared operations', () => { + const plan = resolveOpenApplicationPlan({ remainingCommands: ['snapshot', 'wait'] }); + assert.ok(plan); + assert.ok(plan.operations.includes('captureSnapshot')); + assert.ok(!plan.operations.some((operation) => /^tap/.test(operation))); +}); + +test('batch steps carry the commands still ahead of them through the internal channel', async () => { + const seen: Array<{ command: string; remaining: readonly string[] | undefined }> = []; + const req: DaemonRequest = { + token: 't', + session: 'session', + command: 'batch', + positionals: [], + flags: { batchSteps: [{ command: 'open' }, { command: 'snapshot' }] }, + }; + const response = await runBatchCommands(req, 'session', async (stepRequest) => { + seen.push({ + command: stepRequest.command, + remaining: stepRequest.internal?.executionPlan?.remainingCommands, + }); + return { ok: true, data: {} }; + }); + + assert.equal(response.ok, true); + assert.deepEqual(seen, [ + { command: 'open', remaining: ['snapshot'] }, + { command: 'snapshot', remaining: [] }, + ]); +}); diff --git a/src/daemon/execution-plan.ts b/src/daemon/execution-plan.ts new file mode 100644 index 0000000000..0960ec50e6 --- /dev/null +++ b/src/daemon/execution-plan.ts @@ -0,0 +1,23 @@ +import type { OpenApplicationPlan } from '@agent-device/contracts/application-lifecycle-runtime'; +import { resolvePlannedRuntimeOperations } from '../core/command-descriptor/planned-operations.ts'; + +/** + * The part of a multi-step plan (a `batch`) that is still ahead of the request being executed. + * The batch runner attaches it to each step through the server-private `internal` request channel, + * which the transport strips from every wire request, so a remote client cannot steer platform + * readiness policy with it and ADR 0006 stays untouched. + */ +export type ExecutionPlan = Readonly<{ remainingCommands: readonly string[] }>; + +/** + * The declared runtime operations of the steps still ahead of an `open`, or `undefined` when the + * future is unknown. A plan with no remaining step is unknown too: an `open` that ends its batch + * is a prelude to standalone commands the daemon cannot see yet. + */ +export function resolveOpenApplicationPlan( + plan: ExecutionPlan | undefined, +): OpenApplicationPlan | undefined { + if (plan === undefined || plan.remainingCommands.length === 0) return undefined; + const operations = resolvePlannedRuntimeOperations(plan.remainingCommands); + return operations === undefined ? undefined : { operations }; +} diff --git a/src/daemon/handlers/session-batch.ts b/src/daemon/handlers/session-batch.ts index 1ec5d8595a..4585745cfa 100644 --- a/src/daemon/handlers/session-batch.ts +++ b/src/daemon/handlers/session-batch.ts @@ -1,4 +1,4 @@ -import { type BatchInvoke, runBatch } from '../../core/batch.ts'; +import { runBatch } from '../../core/batch.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; export async function runBatchCommands( @@ -6,5 +6,14 @@ export async function runBatchCommands( sessionName: string, invoke: DaemonInvokeFn, ): Promise { - return await runBatch(req, sessionName, invoke as BatchInvoke); + return await runBatch(req, sessionName, async (stepRequest, context) => { + const step = stepRequest as DaemonRequest; + return await invoke({ + ...step, + internal: { + ...step.internal, + executionPlan: { remainingCommands: context.remainingCommands }, + }, + }); + }); } diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index ac1aeb2329..fc3bbacbfe 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -45,6 +45,7 @@ import { } from '../../session-routing.ts'; import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; +import { resolveOpenApplicationPlan } from '../../execution-plan.ts'; import { abandonDeviceClaim, acquireDeviceClaim, @@ -197,6 +198,7 @@ export async function completeOpenCommand(params: { hasExistingSession: existingSession !== undefined, relaunch: shouldRelaunch, prewarmRunnerBeforeOpen: req.flags?.maestro?.prewarmRunnerBeforeOpen === true, + plan: resolveOpenApplicationPlan(req.internal?.executionPlan), enableTestIme: shouldActivateAndroidTestIme(device, req), stateDir: sessionStore.resolveDaemonStateDir(), runtimeHints: runtimeHintValues(runtimeHints), diff --git a/src/daemon/types.ts b/src/daemon/types.ts index babdfafbb1..d0204093ca 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -1,3 +1,4 @@ +import type { ExecutionPlan } from './execution-plan.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { GestureExecutionProfile } from '@agent-device/contracts/gesture-plan-types'; import type { PreresolvedInteractionTarget } from '@agent-device/contracts/interaction'; @@ -52,6 +53,12 @@ export type DaemonOpenLifecycle = { type DaemonRequestInternal = { publicNetworkOnly?: true; openLifecycle?: DaemonOpenLifecycle; + /** + * The steps a batch still has ahead of this one. The open seam derives platform readiness + * policy (runner demand) from it; the transport strips `internal`, so it never arrives from a + * client. + */ + executionPlan?: ExecutionPlan; /** * Request-owned capability used when a fresh replay discovers its device * only inside the first open. The router retains that device's execution diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 9ebfc7aecb..52adf88fd3 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -76,6 +76,11 @@ export function createAppleApplicationTools(): AppleApplicationTools { const { stopIosRunnerSession } = await loadRunnerOperations(); await stopIosRunnerSession(deviceId); }, + hasLiveRunnerSession: async (deviceId) => { + const { getRunnerSessionSnapshot } = + await import('@agent-device/platform-apple/runner/operations'); + return getRunnerSessionSnapshot(deviceId)?.alive === true; + }, scheduleRunnerIdleStop: (deviceId) => { void loadRunnerOperations().then(({ scheduleIosRunnerIdleStop }) => scheduleIosRunnerIdleStop(deviceId), From b2e405ffff04fca7bba38e24cf5ba613953ab68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 23:57:05 +0200 Subject: [PATCH 02/21] test(fixtures): share one inert audio-probe host across the platform runtime fixtures The Apple and Android runtime fixtures carried identical audio-probe doubles; host-kit now owns the one copy and both fixtures import it. Also folds the two Apple native-find ports onto one admission helper and lifts the Simulator runner prewarm policy out of the open sequence, keeping both under the complexity gate. --- packages/host-kit/package.json | 4 ++ packages/host-kit/src/audio-probe.fixtures.ts | 26 ++++++++ .../platform-android/src/runtime.fixtures.ts | 19 +----- packages/platform-apple/src/lifecycle.ts | 55 ++++++++++------ .../platform-apple/src/runtime-snapshot.ts | 64 ++++++++++--------- .../platform-apple/src/runtime.fixtures.ts | 19 +----- scripts/layering/package-boundaries.test.ts | 2 + 7 files changed, 106 insertions(+), 83 deletions(-) create mode 100644 packages/host-kit/src/audio-probe.fixtures.ts diff --git a/packages/host-kit/package.json b/packages/host-kit/package.json index f3c4c3d0fc..4cb50ed6f1 100644 --- a/packages/host-kit/package.json +++ b/packages/host-kit/package.json @@ -19,6 +19,10 @@ "types": "./src/archive.ts", "default": "./src/archive.ts" }, + "./audio-probe-fixtures": { + "types": "./src/audio-probe.fixtures.ts", + "default": "./src/audio-probe.fixtures.ts" + }, "./command": { "types": "./src/command.ts", "default": "./src/command.ts" diff --git a/packages/host-kit/src/audio-probe.fixtures.ts b/packages/host-kit/src/audio-probe.fixtures.ts new file mode 100644 index 0000000000..f616b54319 --- /dev/null +++ b/packages/host-kit/src/audio-probe.fixtures.ts @@ -0,0 +1,26 @@ +import type { AudioProbeRuntimeHost } from '@agent-device/contracts/audio-probe-runtime-host'; + +/** + * The audio probe host every platform runtime fixture shares: it identifies itself as a fixture, + * owns no process, and refuses to start a capture. Shared here so no platform package carries its + * own copy of a double that has nothing platform-specific in it. + */ +export function inertAudioProbeHost(): AudioProbeRuntimeHost { + return { + hostCapture: { + info: { + source: 'system-audio', + backend: 'fixture', + sourceCount: 0, + notes: () => [], + }, + start: async () => { + throw new Error('Audio probe is outside this runtime fixture.'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + }, + web: { resolve: async () => undefined }, + ownedProcesses: { replace: () => {}, clear: () => {} }, + }; +} diff --git a/packages/platform-android/src/runtime.fixtures.ts b/packages/platform-android/src/runtime.fixtures.ts index b781270f40..0e7a5c9be4 100644 --- a/packages/platform-android/src/runtime.fixtures.ts +++ b/packages/platform-android/src/runtime.fixtures.ts @@ -1,3 +1,4 @@ +import { inertAudioProbeHost } from '@agent-device/host-kit/audio-probe-fixtures'; import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; import type { @@ -21,23 +22,7 @@ export const UNKNOWN_KIND_DEVICE = { kind: 'unknown', } as unknown as DeviceInfo; -const audioProbeHost: PlatformRuntimeHost['audioProbe'] = { - hostCapture: { - info: { - source: 'system-audio', - backend: 'fixture', - sourceCount: 0, - notes: () => [], - }, - start: async () => { - throw new Error('Audio probe is outside this runtime fixture.'); - }, - inspectProcess: async () => 'missing', - terminateProcess: async () => 'already-missing', - }, - web: { resolve: async () => undefined }, - ownedProcesses: { replace: () => {}, clear: () => {} }, -}; +const audioProbeHost: PlatformRuntimeHost['audioProbe'] = inertAudioProbeHost(); function localAndroidScreenRecording() { return { diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 3b4ec7bb88..0d7e664881 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -5,6 +5,7 @@ import { type CloseApplicationInput, type OpenApplicationInput, type OpenApplicationOutcome, + type OpenApplicationRunnerDemand, type PrepareAppleRunnerInput, type PrepareAppleRunnerResult, hasRuntimeTransportHintValues, @@ -85,26 +86,50 @@ export function bindAppleApplicationLifecycle( type BoundAppleInteractor = ReturnType; -async function openAppleApplication( - host: AppleLifecycleHost, - binding: BoundAppleInteractor, +type RunnerPrewarmPolicy = Readonly<{ + runnerDemand?: OpenApplicationRunnerDemand; + shouldPrewarmRunner: boolean; + awaitPrewarmAfterOpen: boolean; +}>; + +/** + * Only a local Simulator has a runner-free observation path (the host AX bridge), so only it + * consults the plan and never waits for runner readiness after the open: bridge observation does + * not need it, and the first runner-dependent command awaits the same startup under the runner + * session lock. Physical devices keep their runner lifecycle unchanged. + */ +function resolveRunnerPrewarmPolicy( + device: DeviceInfo, input: OpenApplicationInput, -): Promise { - const timing: MutableOpenTiming = {}; - const localIosSimulator = isIosSimulator(binding.device); - const runner = createRunnerPrewarm(host, binding, input, timing); - // Only a local Simulator has a runner-free observation path (the host AX bridge), so only it - // consults the plan. Physical devices keep their runner lifecycle unchanged. + localIosSimulator: boolean, +): RunnerPrewarmPolicy { const runnerDemand = localIosSimulator ? resolveAppleSimulatorRunnerDemand(input.plan) : undefined; - if (runnerDemand) timing.runnerDemand = runnerDemand; const shouldPrewarmRunner = - isIosFamily(binding.device) && + isIosFamily(device) && input.surface === 'app' && input.positionals.length > 0 && Boolean(input.appBundleId) && runnerDemand !== 'none'; + return { + ...(runnerDemand ? { runnerDemand } : {}), + shouldPrewarmRunner, + awaitPrewarmAfterOpen: input.relaunch && !localIosSimulator, + }; +} + +async function openAppleApplication( + host: AppleLifecycleHost, + binding: BoundAppleInteractor, + input: OpenApplicationInput, +): Promise { + const timing: MutableOpenTiming = {}; + const localIosSimulator = isIosSimulator(binding.device); + const runner = createRunnerPrewarm(host, binding, input, timing); + const policy = resolveRunnerPrewarmPolicy(binding.device, input, localIosSimulator); + if (policy.runnerDemand) timing.runnerDemand = policy.runnerDemand; + const { shouldPrewarmRunner } = policy; const retainRunnerForRelaunch = shouldRetainRunnerForRelaunch( binding.device, input, @@ -124,13 +149,7 @@ async function openAppleApplication( await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, input.prewarmRunnerBeforeOpen); const runnerTargetPredatesOpen = runner.wasAwaited(); await dispatchAppleOpen(binding, input, localIosSimulator, timing); - // A Simulator open never waits for runner readiness: bridge observation does not need it and - // the first runner-dependent command awaits the same startup under the runner session lock. - await finishAppleRunnerPrewarm( - runner, - shouldPrewarmRunner, - input.relaunch && !localIosSimulator, - ); + await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, policy.awaitPrewarmAfterOpen); await notifyAppleRunnerRelaunch( host, binding, diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index c98d2df73b..b2e6a849dc 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -84,24 +84,14 @@ export function bindAppleFindTextRuntime( ): Pick { return Object.freeze({ findText: async (input: FindTextInput): Promise => { - const appBundleId = input.options?.appBundleId; - if (appBundleId === undefined) return { found: false }; - if (isMacOs(request.device) && input.options?.surface !== undefined) { - if (input.options.surface !== 'app') return { found: false }; - } - const signal = - input.signal === undefined - ? request.signal - : AbortSignal.any([request.signal, input.signal]); - signal.throwIfAborted(); - if (await simulatorRunnerNotLive(host, request.device)) return { found: false }; + const admitted = await admitAppleNativeFind(host, request, input); + if (!admitted) return { found: false }; const interactor = await host.localInteractors.resolve(request.device, { ...input.execution, - appBundleId, - signal, + ...admitted, }); if (!interactor.findText) return { found: false }; - return await interactor.findText(input.text, { appBundleId, signal }); + return await interactor.findText(input.text, admitted); }, }); } @@ -113,31 +103,43 @@ export function bindAppleFindSelectorRuntime( ): Pick { return Object.freeze({ findSelector: async (input: FindSelectorInput): Promise => { - const appBundleId = input.options?.appBundleId; - if (appBundleId === undefined) return { found: false }; - if ( - isMacOs(request.device) && - input.options?.surface !== undefined && - input.options.surface !== 'app' - ) { - return { found: false }; - } - const signal = input.signal - ? AbortSignal.any([request.signal, input.signal]) - : request.signal; - signal.throwIfAborted(); - if (await simulatorRunnerNotLive(host, request.device)) return { found: false }; + const admitted = await admitAppleNativeFind(host, request, input); + if (!admitted) return { found: false }; const interactor = await host.localInteractors.resolve(request.device, { ...input.execution, - appBundleId, - signal, + ...admitted, }); if (!interactor.findSelector) return { found: false }; - return await interactor.findSelector(input.selector, { appBundleId, signal }); + return await interactor.findSelector(input.selector, admitted); }, }); } +type AdmittedAppleNativeFind = Readonly<{ appBundleId: string; signal: AbortSignal }>; + +/** + * The one admission both native find ports share (conditions listed on `bindAppleFindTextRuntime`). + * `undefined` means "not proven here"; an admitted find carries the app scope and the composed + * request/poll signal the runner call needs. + */ +async function admitAppleNativeFind( + host: Pick, + request: Readonly<{ device: DeviceInfo; signal: AbortSignal }>, + input: Readonly<{ + options?: Readonly<{ appBundleId?: string; surface?: string }>; + signal?: AbortSignal; + }>, +): Promise { + const appBundleId = input.options?.appBundleId; + if (appBundleId === undefined) return undefined; + const surface = input.options?.surface; + if (isMacOs(request.device) && surface !== undefined && surface !== 'app') return undefined; + const signal = input.signal ? AbortSignal.any([request.signal, input.signal]) : request.signal; + signal.throwIfAborted(); + if (await simulatorRunnerNotLive(host, request.device)) return undefined; + return { appBundleId, signal }; +} + /** A local iOS Simulator whose runner session is not alive; see the find-runtime doc above. */ async function simulatorRunnerNotLive( host: Pick, diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index a81e713f0d..9ad2675b48 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -1,3 +1,4 @@ +import { inertAudioProbeHost } from '@agent-device/host-kit/audio-probe-fixtures'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { Interactor } from '@agent-device/contracts/interactor-types'; import { hostFixture } from './logs/runtime.fixtures.ts'; @@ -29,23 +30,7 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { }, }, localInteractors: { resolve: async () => ({}) as Interactor }, - audioProbe: { - hostCapture: { - info: { - source: 'system-audio', - backend: 'fixture', - sourceCount: 0, - notes: () => [], - }, - start: async () => { - throw new Error('Audio probe is outside this runtime fixture.'); - }, - inspectProcess: async () => 'missing', - terminateProcess: async () => 'already-missing', - }, - web: { resolve: async () => undefined }, - ownedProcesses: { replace: () => {}, clear: () => {} }, - }, + audioProbe: inertAudioProbeHost(), applicationResources: { recoverStartupResources: async () => {}, detachForDaemonShutdown: async () => {}, diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 11b015d1af..9e6d372396 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -452,6 +452,8 @@ test('the real tree parses, declares, and passes R11', () => { ); assert.deepEqual([...hostKitPackage.exportTargets.keys()].sort(), [ '@agent-device/host-kit/archive', + // Test-only entry: the one inert audio-probe double the platform runtime fixtures share. + '@agent-device/host-kit/audio-probe-fixtures', '@agent-device/host-kit/command', '@agent-device/host-kit/diagnostics', '@agent-device/host-kit/file', From 15d71d6d26918f570e20987818140c84a601a9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 00:14:14 +0200 Subject: [PATCH 03/21] fix(ios): answer runner liveness through the runner provider seam The find ports and the relaunch target reset asked the local session registry whether a runner was alive, which misreads scripted and request-scoped runner providers as absent. Liveness is now a provider question: the local provider consults its session registry, a provider without startup cost counts as live, and an awaited prewarm proves liveness without asking. --- .../src/application-lifecycle-runtime.ts | 10 +++-- .../platform-apple/src/core/runner-client.ts | 2 + packages/platform-apple/src/lifecycle.test.ts | 2 +- packages/platform-apple/src/lifecycle.ts | 4 +- .../src/runner-operations-facade.ts | 1 + .../runner-client-live-session.test.ts | 42 +++++++++++++++++++ packages/platform-apple/src/runner/client.ts | 3 ++ .../src/runner/runner-client.ts | 15 +++++++ .../src/runner/runner-provider.ts | 7 +++- .../platform-apple/src/runtime-snapshot.ts | 6 ++- .../__tests__/session-relaunch-close.test.ts | 2 + ...latform-runtime-apple-application-tools.ts | 6 +-- 12 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index e983ccaa76..93d966c876 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -294,10 +294,14 @@ export type AppleApplicationTools = Readonly<{ ): Promise; stopRunnerSession(deviceId: string): Promise; /** - * Whether a runner session for this device is already alive. A starting session is not alive: - * observation paths use this to avoid awaiting runner readiness they do not need. + * Whether asking this device's runner now would be answered without a startup wait. A starting + * session is not live; a runner with no startup cost is. Observation paths use it to avoid + * awaiting runner readiness they do not need. */ - hasLiveRunnerSession(deviceId: string): Promise; + hasLiveRunnerSession( + device: DeviceInfo, + execution: Readonly<{ requestId?: string }>, + ): Promise; scheduleRunnerIdleStop(deviceId: string): void; prepareRunner( device: DeviceInfo, diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 5c1f1f8aab..09ce610428 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -20,6 +20,8 @@ export const runAppleRunnerCommand: AppleRunnerClient['runAppleRunnerCommand'] = client.runAppleRunnerCommand; export const notifyIosRunnerAppRelaunched: AppleRunnerClient['notifyIosRunnerAppRelaunched'] = client.notifyIosRunnerAppRelaunched; +export const hasLiveIosRunnerSession: AppleRunnerClient['hasLiveIosRunnerSession'] = + client.hasLiveIosRunnerSession; export const prewarmAppleRunnerCache: AppleRunnerClient['prewarmAppleRunnerCache'] = client.prewarmAppleRunnerCache; export const prewarmIosRunnerSession: AppleRunnerClient['prewarmIosRunnerSession'] = diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 1531f6c498..200a7f33a1 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -384,7 +384,7 @@ test('a Simulator relaunch resets the target only on a runner that is already al await lifecycle.openApplication({ ...openInput(), relaunch: true }); - expect(hasLiveRunnerSession).toHaveBeenCalledWith(simulator.id); + expect(hasLiveRunnerSession).toHaveBeenCalledWith(simulator, {}); expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(simulator, {}, signal); expect(events).toEqual(['prewarm', 'open', 'reset']); }); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 0d7e664881..1cb0ea9456 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -291,9 +291,11 @@ async function notifyAppleRunnerRelaunch( } // Only a runner that is already alive can hold a stale cached target. A starting runner has // none, and asking it would await its startup; a fresh one re-resolves the target on first use. + // An awaited prewarm proved liveness already. if ( localIosSimulator && - !(await host.appleApplications.hasLiveRunnerSession(binding.device.id)) + !runnerTargetPredatesOpen && + !(await host.appleApplications.hasLiveRunnerSession(binding.device, input.execution)) ) { return; } diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 15e5e56130..e27bfb41b6 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -2,6 +2,7 @@ export { applyXctestRunnerAppIconFromDerivedPath, detachIosSimulatorRunnerSessionsForShutdown, getRunnerSessionSnapshot, + hasLiveIosRunnerSession, notifyIosRunnerAppRelaunched, prepareIosRunner, prewarmAppleRunnerCache, diff --git a/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts new file mode 100644 index 0000000000..4e42005c6d --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { hasLiveIosRunnerSession } from '../runner-client.ts'; +import { withAppleRunnerProvider } from '../runner-provider.ts'; + +const simulator: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-live', + name: 'iPhone', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +test('the local runner is live only while its session registry holds an alive session', () => { + expect(hasLiveIosRunnerSession(simulator)).toBe(false); +}); + +test('a scoped provider without startup cost counts as live', async () => { + await withAppleRunnerProvider( + async () => ({}), + { deviceId: simulator.id }, + async () => { + expect(hasLiveIosRunnerSession(simulator)).toBe(true); + }, + ); +}); + +test('a scoped provider that tracks its own sessions answers for itself', async () => { + await withAppleRunnerProvider( + { runCommand: async () => ({}), hasLiveSession: () => false }, + { deviceId: simulator.id }, + async () => { + expect(hasLiveIosRunnerSession(simulator)).toBe(false); + }, + ); +}); + +test('a non-iOS device never reports a live iOS runner', () => { + expect(hasLiveIosRunnerSession({ ...simulator, appleOs: 'macos', kind: 'device' })).toBe(false); +}); diff --git a/packages/platform-apple/src/runner/client.ts b/packages/platform-apple/src/runner/client.ts index fd4101f304..32854c3441 100644 --- a/packages/platform-apple/src/runner/client.ts +++ b/packages/platform-apple/src/runner/client.ts @@ -1,5 +1,6 @@ import { bindAppleRunnerHost, type AppleRunnerHost } from './host.ts'; import { + hasLiveIosRunnerSession, notifyIosRunnerAppRelaunched, prepareIosRunner, prewarmAppleRunnerCache, @@ -33,6 +34,7 @@ import { hasCachedAppleRunnerArtifact, resolveRunnerAppBundleId } from './runner export type AppleRunnerClient = { runAppleRunnerCommand: typeof runAppleRunnerCommand; notifyIosRunnerAppRelaunched: typeof notifyIosRunnerAppRelaunched; + hasLiveIosRunnerSession: typeof hasLiveIosRunnerSession; prewarmAppleRunnerCache: typeof prewarmAppleRunnerCache; prewarmIosRunnerSession: typeof prewarmIosRunnerSession; prepareIosRunner: typeof prepareIosRunner; @@ -62,6 +64,7 @@ export function createAppleRunnerClient(host: AppleRunnerHost): AppleRunnerClien return { runAppleRunnerCommand, notifyIosRunnerAppRelaunched, + hasLiveIosRunnerSession, prewarmAppleRunnerCache, prewarmIosRunnerSession, prepareIosRunner, diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index 4bf490d9dc..1c2147a090 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -2,6 +2,7 @@ import { retryWithPolicy, emitDiagnostic } from './host.ts'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { ensureRunnerSession, + getRunnerSessionSnapshot, stopIosRunnerSession, validateRunnerDevice, } from './runner-session.ts'; @@ -176,8 +177,22 @@ function resolveAppleRunnerRuntime( }); } +/** + * Whether asking this device's runner now would wait for a startup. Observation paths use it to + * stay runner-free while no session is alive; a runner that is already up keeps answering. + */ +export function hasLiveIosRunnerSession( + device: DeviceInfo, + options: { requestId?: string } = {}, +): boolean { + if (!isIosFamily(device)) return false; + const provider = resolveAppleRunnerRuntime(device, options); + return provider.hasLiveSession ? provider.hasLiveSession(device) : true; +} + const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, { prepare: prepareLocalIosRunner, + hasLiveSession: (device) => getRunnerSessionSnapshot(device.id)?.alive === true, prewarm: async (device, options) => { const { healthCheck, ...runnerOptions } = options; if (healthCheck === false) { diff --git a/packages/platform-apple/src/runner/runner-provider.ts b/packages/platform-apple/src/runner/runner-provider.ts index e0cb556ac9..5a295ef7a0 100644 --- a/packages/platform-apple/src/runner/runner-provider.ts +++ b/packages/platform-apple/src/runner/runner-provider.ts @@ -74,6 +74,11 @@ export type AppleRunnerProvider = { * Starts runner setup opportunistically. This must remain best-effort. */ prewarm?: AppleRunnerPrewarmExecutor; + /** + * Whether a command sent now is answered without waiting for a runner startup. A provider with + * no startup cost (scripted, request-scoped transports) omits this and counts as live. + */ + hasLiveSession?: (device: DeviceInfo) => boolean; }; export type AppleRunnerProviderScopeOptions = { @@ -91,7 +96,7 @@ const appleRunnerProviderScope = new AsyncLocalStorage export function createLocalAppleRunnerProvider( runCommand: AppleRunnerCommandExecutor, - lifecycle: Pick = {}, + lifecycle: Pick = {}, ): AppleRunnerProvider { return { runCommand, ...lifecycle }; } diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index b2e6a849dc..e1e5359a6d 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -127,6 +127,7 @@ async function admitAppleNativeFind( request: Readonly<{ device: DeviceInfo; signal: AbortSignal }>, input: Readonly<{ options?: Readonly<{ appBundleId?: string; surface?: string }>; + execution?: Readonly<{ requestId?: string }>; signal?: AbortSignal; }>, ): Promise { @@ -136,7 +137,7 @@ async function admitAppleNativeFind( if (isMacOs(request.device) && surface !== undefined && surface !== 'app') return undefined; const signal = input.signal ? AbortSignal.any([request.signal, input.signal]) : request.signal; signal.throwIfAborted(); - if (await simulatorRunnerNotLive(host, request.device)) return undefined; + if (await simulatorRunnerNotLive(host, request.device, input.execution)) return undefined; return { appBundleId, signal }; } @@ -144,7 +145,8 @@ async function admitAppleNativeFind( async function simulatorRunnerNotLive( host: Pick, device: DeviceInfo, + execution: Readonly<{ requestId?: string }> | undefined, ): Promise { if (!isIosFamily(device) || device.kind !== 'simulator') return false; - return !(await host.appleApplications.hasLiveRunnerSession(device.id)); + return !(await host.appleApplications.hasLiveRunnerSession(device, execution ?? {})); } diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 2b8be533ab..92dd74fe66 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -27,6 +27,8 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) prewarmAppleRunnerCache: vi.fn(), prewarmIosRunnerSession: vi.fn(), notifyIosRunnerAppRelaunched: vi.fn(async () => {}), + // A retained Simulator runner survives the relaunch, so its cached target is reset. + hasLiveIosRunnerSession: vi.fn(() => true), scheduleIosRunnerIdleStop: vi.fn(), stopIosRunnerSession: vi.fn(async () => {}), }; diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 52adf88fd3..9c49db9e94 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -76,10 +76,10 @@ export function createAppleApplicationTools(): AppleApplicationTools { const { stopIosRunnerSession } = await loadRunnerOperations(); await stopIosRunnerSession(deviceId); }, - hasLiveRunnerSession: async (deviceId) => { - const { getRunnerSessionSnapshot } = + hasLiveRunnerSession: async (device, execution) => { + const { hasLiveIosRunnerSession } = await import('@agent-device/platform-apple/runner/operations'); - return getRunnerSessionSnapshot(deviceId)?.alive === true; + return hasLiveIosRunnerSession(device, { requestId: execution.requestId }); }, scheduleRunnerIdleStop: (deviceId) => { void loadRunnerOperations().then(({ scheduleIosRunnerIdleStop }) => From 10248878c4e51b659146f600e1d41beebc4fa91b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 00:44:03 +0200 Subject: [PATCH 04/21] perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace A snapshot, diff, or find step now selects the runtime uses its structured input reaches, the way its handler does, so a plain snapshot no longer counts the custom-actions alternative and an observation-only batch resolves runner demand none. The descriptor declares the selector next to its alternatives; the daemon plan derivation honors it and keeps the union for every other command. Without the runner wait, the first snapshot after an open reached the AX bridge while the app was still becoming the primary foreground owner or registering its accessibility server, and the typed fallback then started the runner the plan had just avoided. A target younger than ten seconds is re-read for a bounded grace measured from the first such failure: five seconds for a missing AX server, one second for an ownership miss so a launch-time system dialog still reaches the fallback quickly. Established targets get no grace. --- .../src/command-platform-execution.test.ts | 12 ++ .../src/command-platform-execution.ts | 18 ++ .../src/platform-runtime-operations.ts | 30 ++++ .../platform-apple/src/snapshot-route.test.ts | 158 ++++++++++++++++++ packages/platform-apple/src/snapshot-route.ts | 55 +++++- src/core/__tests__/batch.test.ts | 44 ++--- src/core/batch.ts | 10 +- .../__tests__/planned-operations.test.ts | 73 ++++++-- .../command-descriptor/planned-operations.ts | 29 +++- src/core/command-descriptor/registry.ts | 36 ++-- src/daemon/__tests__/execution-plan.test.ts | 12 +- src/daemon/execution-plan.ts | 11 +- src/daemon/handlers/session-batch.ts | 2 +- 13 files changed, 421 insertions(+), 69 deletions(-) diff --git a/packages/contracts/src/command-platform-execution.test.ts b/packages/contracts/src/command-platform-execution.test.ts index a6722fdb4e..fd1ce64063 100644 --- a/packages/contracts/src/command-platform-execution.test.ts +++ b/packages/contracts/src/command-platform-execution.test.ts @@ -55,7 +55,19 @@ describe('command platform execution declaration', () => { ).not.toThrow(); }); + test('accepts a step selector next to input-dependent runtime uses', () => { + expect(() => + assertCommandPlatformExecution({ + kind: 'device-runtime', + uses: appLogRuntimePlanUses, + selectUses: () => appLogRuntimePlanUses, + }), + ).not.toThrow(); + }); + test.each([ + { kind: 'device-runtime', uses: appLogRuntimePlanUses, selectUses: 'not-a-function' }, + { kind: 'device-runtime', use: appLogRuntimePlanUses[0], selectUses: () => [] }, { kind: 'device-runtime', uses: [] }, { kind: 'device-runtime', use: appLogRuntimePlanUses[0], uses: appLogRuntimePlanUses }, { kind: 'device-runtime', uses: [appLogRuntimePlanUses[0], appLogRuntimePlanUses[0]] }, diff --git a/packages/contracts/src/command-platform-execution.ts b/packages/contracts/src/command-platform-execution.ts index d89fe07e04..c931059a4d 100644 --- a/packages/contracts/src/command-platform-execution.ts +++ b/packages/contracts/src/command-platform-execution.ts @@ -2,6 +2,15 @@ import type { InventoryUse } from './platform-module.ts'; import type { RuntimeUseDeclaration } from './platform-runtime.ts'; import { runtimeUseIdentity } from './platform-runtime-use.ts'; +/** + * Plan-time selection of the runtime uses one structured step input can reach, for a command whose + * declared alternatives differ in what they execute. Session-dependent splits (active app) stay + * open, so a selector returns every alternative the input still admits. + */ +export type RuntimeUseStepSelector = ( + input: Readonly> | undefined, +) => readonly RuntimeUseDeclaration[]; + export type CommandPlatformExecution = | Readonly<{ kind: 'none' }> /** @@ -15,6 +24,7 @@ export type CommandPlatformExecution = | Readonly<{ kind: 'device-runtime'; uses: readonly [RuntimeUseDeclaration, ...RuntimeUseDeclaration[]]; + selectUses?: RuntimeUseStepSelector; }>; // The discriminated union cannot prove uniqueness or operation-category disjointness inside @@ -48,6 +58,14 @@ export function assertCommandPlatformExecution( ) { return; } + if ( + declaration['kind'] === 'device-runtime' && + sameKeys(keys, ['kind', 'selectUses', 'uses']) && + typeof declaration['selectUses'] === 'function' && + hasRuntimeUseDeclarations(declaration['uses']) + ) { + return; + } throw invalidPlatformExecution(); } diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3b9cff9f81..3ba1d459b6 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -54,6 +54,7 @@ import { type DeviceRuntimeOwner, type RuntimeOwnerRef, type RuntimePlatformModule, + type RuntimeUseDeclaration, } from './platform-runtime.ts'; import { runtimeUse } from './platform-runtime-use.ts'; import type { AndroidToolHost } from './platform-runtime-host.ts'; @@ -597,6 +598,35 @@ export function resolveSnapshotRuntimePlan(input: { }); } +/** + * The snapshot uses a structured `snapshot`/`diff` step reaches, read the way the handler reads its + * plan: custom actions select the XCTest-only alternative, and the active-app split stays open. + */ +export function selectSnapshotStepUses( + input: Readonly> | undefined, +): readonly RuntimeUseDeclaration[] { + const customActions = input?.['customActions'] === true; + return [true, false].map( + (hasActiveApp) => resolveSnapshotRuntimePlan({ customActions, hasActiveApp }).use, + ); +} + +/** + * The selector uses a structured `find` step reaches, mirroring the handler's action-selected + * plan: `focus` and `type` bind their combined leg, a text read binds the element-text capture, + * and an action with a delegated leg keeps every declared alternative. + */ +export function selectFindStepUses( + input: Readonly> | undefined, +): readonly RuntimeUseDeclaration[] { + const action = input?.['action']; + if (action === undefined || action === 'wait') return selectorUsesByIntent['capture-only']; + if (action === 'getText' || action === 'getAttrs') return selectorUsesByIntent['element-text']; + if (action === 'focus') return selectorUsesByIntent['find-focus']; + if (action === 'type') return selectorUsesByIntent['find-type']; + return findRuntimePlanUses; +} + const captureScreenshotUse = defineUse({ required: ['captureScreenshot'] }); /** * Screenshot post-processing that resolves a snapshot taken in the same request — `--overlay-refs` diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index ce1525ceb9..9ad0426f25 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -238,3 +238,161 @@ function runnerResult() { function signal(): AbortSignal { return new AbortController().signal; } + +const OWNER_UNVERIFIED = { + stage: 'failed', + failure: { kind: 'unsupported', code: 'foreground-owner-unverified' }, +} as const satisfies SnapshotSourceOutcome; + +function lstart(msAgo: number, nowMs: number): string { + return new Date(nowMs - msAgo).toString().replace(/ GMT.*$/, ''); +} + +function launchGraceRoute( + outcomes: readonly SnapshotSourceOutcome[], + processStartTime: string, + clock: { now(): number; sleep(ms: number, signal?: AbortSignal): Promise }, +) { + const acquire = vi.fn( + async () => outcomes[Math.min(acquire.mock.calls.length - 1, outcomes.length - 1)]!, + ); + const presentIosAcquisition = vi.fn(async () => ({ + backend: 'xctest' as const, + producer: 'simulator-ax-bridge' as const, + nodes: [{ index: 0, type: 'Application' }], + })); + const fallback = vi.fn(async () => runnerResult()); + const route = createAppleSnapshotRoute( + { + ...platformRuntimeHostFixture(), + clock, + snapshot: { captureSurface: vi.fn(), presentIosAcquisition }, + }, + { + source: { acquire, close: vi.fn(async () => {}) }, + resolveTarget: vi.fn(async () => ({ ...target, processStartTime })), + }, + ); + return { route, acquire, fallback, presentIosAcquisition }; +} + +test('a foreground-owner miss on a just-launched target is re-read inside the launch grace', async () => { + const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); + const sleep = vi.fn(async () => {}); + const { route, acquire, fallback } = launchGraceRoute( + [OWNER_UNVERIFIED, bridgeAcquisition()], + lstart(800, nowMs), + { now: () => nowMs, sleep }, + ); + + await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ + producer: 'simulator-ax-bridge', + }); + expect(acquire).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledOnce(); + expect(fallback).not.toHaveBeenCalled(); +}); + +test('an unregistered AX server on a just-launched target is re-read inside the launch grace', async () => { + const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); + const { route, acquire, fallback } = launchGraceRoute( + [ + { + stage: 'failed', + failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, + }, + bridgeAcquisition(), + ], + lstart(1_200, nowMs), + { now: () => nowMs, sleep: async () => {} }, + ); + + await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ + producer: 'simulator-ax-bridge', + }); + expect(acquire).toHaveBeenCalledTimes(2); + expect(fallback).not.toHaveBeenCalled(); +}); + +test('a bridge transport loss on a just-launched target is not a launch transition', async () => { + const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); + const { route, acquire, fallback } = launchGraceRoute( + [{ stage: 'failed', failure: { kind: 'transport-failure', code: 'bridge-disconnected' } }], + lstart(500, nowMs), + { now: () => nowMs, sleep: async () => {} }, + ); + + await route.capture(ios, input, signal(), fallback); + expect(acquire).toHaveBeenCalledOnce(); + expect(fallback).toHaveBeenCalledOnce(); +}); + +test('a foreground-owner miss on an established target falls back at once', async () => { + const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); + const sleep = vi.fn(async () => {}); + const { route, acquire, fallback } = launchGraceRoute( + [OWNER_UNVERIFIED, bridgeAcquisition()], + lstart(60_000, nowMs), + { now: () => nowMs, sleep }, + ); + + await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ + warnings: [expect.stringContaining('foreground-owner-unverified')], + }); + expect(acquire).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); + expect(fallback).toHaveBeenCalledOnce(); +}); + +test('the ownership grace is one second from the first miss, then the typed fallback applies', async () => { + let nowMs = Date.parse('2026-09-06T00:00:10.000Z'); + const startedAt = lstart(4_000, nowMs); + const sleep = vi.fn(async () => { + nowMs += 400; + }); + const { route, acquire, fallback } = launchGraceRoute([OWNER_UNVERIFIED], startedAt, { + now: () => nowMs, + sleep, + }); + + await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ + warnings: [expect.stringContaining('foreground-owner-unverified')], + }); + expect(acquire.mock.calls.length).toBeGreaterThan(1); + expect(acquire.mock.calls.length).toBeLessThanOrEqual(4); + expect(fallback).toHaveBeenCalledOnce(); +}); + +test('the AX-server grace outlives the bridge cold start that consumed the launch', async () => { + // First failure observed 6 s after the process appeared (a cold bridge start), still young. + let nowMs = Date.parse('2026-09-06T00:00:10.000Z'); + const startedAt = lstart(6_000, nowMs); + const sleep = vi.fn(async () => { + nowMs += 500; + }); + const { route, acquire, fallback } = launchGraceRoute( + [ + { + stage: 'failed', + failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, + }, + { + stage: 'failed', + failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, + }, + { + stage: 'failed', + failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, + }, + bridgeAcquisition(), + ], + startedAt, + { now: () => nowMs, sleep }, + ); + + await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ + producer: 'simulator-ax-bridge', + }); + expect(acquire).toHaveBeenCalledTimes(4); + expect(fallback).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index c4ae56cc08..db92be1152 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -31,6 +31,25 @@ import { type SnapshotFallback = (input: CaptureSnapshotInput) => Promise; +/** + * A freshly launched app is not yet the primary foreground owner while SpringBoard animates it in, + * and its accessibility server registers a moment after its process appears. The bridge reports + * those states as typed failures. Falling back on them would start the XCTest runner for a snapshot + * the bridge serves a moment later, so a young target is re-read for a bounded grace before the + * typed fallback applies. The grace is measured from the first failure, because the bridge's own + * cold start may already have consumed the launch, and it is short for ownership misses: a system + * dialog produces the same code and must still reach the fallback quickly. An established target + * gets no grace at all. + */ +const LAUNCH_YOUNG_TARGET_MS = 10_000; +const LAUNCH_GRACE_POLL_MS = 150; +const LAUNCH_GRACE_BY_CODE: ReadonlyMap = new Map([ + ['application-element-missing', 5_000], + ['application-server-unavailable', 5_000], + ['foreground-owner-unverified', 1_000], + ['foreground-owner-changed', 1_000], +]); + export type AppleSnapshotRoute = Readonly<{ capture( device: DeviceInfo, @@ -79,11 +98,24 @@ export function createAppleSnapshotRoute( } const request = requestFor(input); - const outcome = await source.acquire({ - target, - hint: deriveIosCaptureHint(request), - signal, - }); + const acquire = async () => + await source.acquire({ target, hint: deriveIosCaptureHint(request), signal }); + let outcome = await acquire(); + if (outcome.stage === 'failed') { + const graceMs = launchGraceFor(outcome.failure, target, host.clock.now()); + const deadline = host.clock.now() + graceMs; + while ( + outcome.stage === 'failed' && + LAUNCH_GRACE_BY_CODE.has(outcome.failure.code) && + host.clock.now() < deadline + ) { + emitRouteDiagnostic('launch-grace-retry', device, target.generation, undefined, { + code: outcome.failure.code, + }); + await host.clock.sleep(LAUNCH_GRACE_POLL_MS, signal); + outcome = await acquire(); + } + } if (outcome.stage === 'failed') { if (outcome.failure.kind === 'cancelled') { signal.throwIfAborted(); @@ -235,6 +267,19 @@ async function resolveFailureFallbackIdentity( } } +function launchGraceFor( + failure: SnapshotSourceFailure, + target: SimulatorSnapshotTarget, + nowMs: number, +): number { + const graceMs = LAUNCH_GRACE_BY_CODE.get(failure.code); + if (graceMs === undefined) return 0; + // `ps -o lstart=` text; an unparseable start time counts as established, never as young. + const startedAtMs = Date.parse(target.processStartTime); + if (Number.isNaN(startedAtMs)) return 0; + return Math.max(0, nowMs - startedAtMs) < LAUNCH_YOUNG_TARGET_MS ? graceMs : 0; +} + function unknownGenerationResidue(): IosAcquisitionResidue { return { kind: 'unknown-generation', captureId: randomUUID() }; } diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index 34d285bd6b..218cd3f1f8 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -119,31 +119,31 @@ test('default batch (no responseLevel) passes meta through unchanged — byte-id assert.deepEqual(seen, [undefined, undefined, undefined]); }); -test('each step is invoked with its place in the plan and the commands still ahead of it', async () => { - const seen: Array<{ - command: string; - remaining: readonly string[]; - step: number; - total: number; - }> = []; - const response = await runBatch( - batchRequest(['open', 'snapshot', 'click']), - 'session', - async (req, context) => { - seen.push({ - command: req.command, - remaining: context.remainingCommands, - step: context.stepNumber, - total: context.totalSteps, - }); - return { ok: true, data: {} }; - }, - ); +test('each step is invoked with its place in the plan and the steps still ahead of it', async () => { + const seen: Array<{ command: string; remaining: unknown; step: number; total: number }> = []; + const request = batchRequest(['open', 'snapshot', 'click']); + (request.flags as { batchSteps: DaemonBatchStep[] }).batchSteps[1]!.input = { + interactiveOnly: true, + }; + const response = await runBatch(request, 'session', async (req, context) => { + seen.push({ + command: req.command, + remaining: context.remainingSteps, + step: context.stepNumber, + total: context.totalSteps, + }); + return { ok: true, data: {} }; + }); assert.equal(response.ok, true); assert.deepEqual(seen, [ - { command: 'open', remaining: ['snapshot', 'click'], step: 1, total: 3 }, - { command: 'snapshot', remaining: ['click'], step: 2, total: 3 }, + { + command: 'open', + remaining: [{ command: 'snapshot', input: { interactiveOnly: true } }, { command: 'click' }], + step: 1, + total: 3, + }, + { command: 'snapshot', remaining: [{ command: 'click' }], step: 2, total: 3 }, { command: 'click', remaining: [], step: 3, total: 3 }, ]); }); diff --git a/src/core/batch.ts b/src/core/batch.ts index 5b494883ad..1086bbd4ae 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -41,7 +41,10 @@ export type BatchRequest = Omit & { export type BatchStepContext = Readonly<{ stepNumber: number; totalSteps: number; - remainingCommands: readonly string[]; + remainingSteps: readonly Readonly<{ + command: string; + input?: Readonly>; + }>[]; }>; export type BatchInvoke = (req: BatchRequest, context: BatchStepContext) => Promise; @@ -101,7 +104,10 @@ export async function runBatch( const stepResponse = await runBatchStep(req, sessionName, step, invoke, { stepNumber: index + 1, totalSteps: steps.length, - remainingCommands: steps.slice(index + 1).map((remaining) => remaining.command), + remainingSteps: steps.slice(index + 1).map((remaining) => ({ + command: remaining.command, + ...(remaining.input === undefined ? {} : { input: remaining.input }), + })), }); if (!stepResponse.ok) { return { diff --git a/src/core/command-descriptor/__tests__/planned-operations.test.ts b/src/core/command-descriptor/__tests__/planned-operations.test.ts index a568230658..2ca061dd04 100644 --- a/src/core/command-descriptor/__tests__/planned-operations.test.ts +++ b/src/core/command-descriptor/__tests__/planned-operations.test.ts @@ -1,33 +1,86 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { commandDescriptors } from '../registry.ts'; import { resolvePlannedRuntimeOperations } from '../planned-operations.ts'; +const steps = (...commands: string[]) => commands.map((command) => ({ command })); + test('observation commands declare only capture and selector-observation operations', () => { - const operations = resolvePlannedRuntimeOperations(['snapshot', 'wait', 'is', 'screenshot']); + const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'wait', 'is', 'screenshot')); assert.ok(operations); assert.ok(operations.includes('captureSnapshot')); assert.ok(operations.includes('findText')); + assert.ok(!operations.includes('captureSnapshotWithCustomActions')); for (const operation of operations) { assert.doesNotMatch(operation, /^(tap|fill|type|scroll|perform|hover|focus|longPress)/); } }); -test('an interaction command contributes its touch operations', () => { - const operations = resolvePlannedRuntimeOperations(['snapshot', 'click']); - assert.ok(operations); - assert.ok(operations.some((operation) => /^tap/.test(operation))); +test('a snapshot step selects the custom-actions alternative only when its input asks for it', () => { + const plain = resolvePlannedRuntimeOperations([{ command: 'snapshot', input: { depth: 2 } }]); + const custom = resolvePlannedRuntimeOperations([ + { command: 'snapshot', input: { customActions: true } }, + ]); + assert.ok(plain && custom); + assert.ok(!plain.includes('captureSnapshotWithCustomActions')); + assert.ok(custom.includes('captureSnapshotWithCustomActions')); +}); + +test('a find step selects its leg from the action', () => { + const readOnly = resolvePlannedRuntimeOperations([{ command: 'find' }]); + const text = resolvePlannedRuntimeOperations([{ command: 'find', input: { action: 'getText' } }]); + const typed = resolvePlannedRuntimeOperations([{ command: 'find', input: { action: 'type' } }]); + const clicked = resolvePlannedRuntimeOperations([ + { command: 'find', input: { action: 'click' } }, + ]); + assert.ok(readOnly && text && typed && clicked); + assert.deepEqual( + readOnly.filter((operation) => /readTextAtPoint|focusPoint|typeText/.test(operation)), + [], + ); + assert.ok(text.includes('readTextAtPoint')); + assert.ok(typed.includes('typeText')); + // A delegated leg keeps every declared alternative rather than guessing. + assert.ok(clicked.includes('readTextAtPoint') && clicked.includes('typeText')); }); -test('every declared use category counts, so a mutating find is never proven observation-only', () => { - const operations = resolvePlannedRuntimeOperations(['find']); +test('an interaction command contributes its touch operations', () => { + const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'click')); assert.ok(operations); - assert.ok(operations.some((operation) => /^tap|^fill|^type/.test(operation))); + assert.ok(operations.some((operation) => /^tap/.test(operation))); }); test('commands without device runtime execution contribute nothing', () => { - assert.deepEqual(resolvePlannedRuntimeOperations(['devices', 'capabilities']), []); + assert.deepEqual(resolvePlannedRuntimeOperations(steps('devices', 'capabilities')), []); }); test('an unregistered command makes the plan unproven', () => { - assert.equal(resolvePlannedRuntimeOperations(['snapshot', 'not-a-command']), undefined); + assert.equal(resolvePlannedRuntimeOperations(steps('snapshot', 'not-a-command')), undefined); +}); + +test('every step selector returns a non-empty subset of the alternatives its command declares', () => { + const probes: Array | undefined> = [ + undefined, + { customActions: true }, + { action: 'getText' }, + { action: 'type' }, + { action: 'click' }, + ]; + let selectors = 0; + for (const descriptor of commandDescriptors) { + const execution = descriptor.platformExecution; + if (execution.kind !== 'device-runtime' || !('uses' in execution) || !execution.selectUses) { + continue; + } + selectors += 1; + for (const probe of probes) { + const selected = execution.selectUses(probe); + assert.ok(selected.length > 0, `${descriptor.name} selected no use`); + for (const use of selected) { + assert.ok(execution.uses.includes(use), `${descriptor.name} selected an undeclared use`); + } + } + } + // The commands whose declared alternatives differ in what they execute on a device. + assert.equal(selectors, 3); }); diff --git a/src/core/command-descriptor/planned-operations.ts b/src/core/command-descriptor/planned-operations.ts index 6b50335868..b5b9153d49 100644 --- a/src/core/command-descriptor/planned-operations.ts +++ b/src/core/command-descriptor/planned-operations.ts @@ -5,23 +5,34 @@ const descriptorsByName = new Map( commandDescriptors.map((descriptor) => [descriptor.name, descriptor]), ); +/** One step of a plan as the batch runner knows it: the command and its structured input. */ +export type PlannedStep = Readonly<{ + command: string; + input?: Readonly>; +}>; + /** - * The runtime operations a sequence of commands may execute, read from each command's declared - * platform execution (ADR 0019 §6). Every declared category counts — required, preferred, and - * conditional — because a plan is proven observation-only only when no step can reach an - * interaction operation. Returns `undefined` when any command is unknown to the registry: an - * unregistered step makes the plan unproven rather than silently empty. + * The runtime operations a sequence of steps may execute, read from each command's declared + * platform execution (ADR 0019 §6). A command whose alternatives differ selects them from the + * step input the way its handler does (`selectUses`); every other command contributes all of its + * alternatives, and every declared category counts — required, preferred, and conditional — + * because a plan is proven observation-only only when no step can reach an interaction + * operation. Returns `undefined` when any command is unknown to the registry: an unregistered + * step makes the plan unproven rather than silently empty. */ export function resolvePlannedRuntimeOperations( - commands: readonly string[], + steps: readonly PlannedStep[], ): readonly string[] | undefined { const operations = new Set(); - for (const command of commands) { - const descriptor = descriptorsByName.get(command); + for (const step of steps) { + const descriptor = descriptorsByName.get(step.command); if (!descriptor) return undefined; const execution = descriptor.platformExecution; if (execution.kind !== 'device-runtime') continue; - const uses = 'uses' in execution ? execution.uses : [execution.use]; + const uses = + 'uses' in execution + ? (execution.selectUses?.(step.input) ?? execution.uses) + : [execution.use]; for (const use of uses) { for (const operation of [...use.required, ...use.preferred, ...(use.conditional ?? [])]) { operations.add(operation); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index aa1354cd4e..d79be29f4b 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -29,10 +29,14 @@ import { audioRuntimePlanUses } from '@agent-device/contracts/audio-runtime-plan import { networkDumpUse } from '@agent-device/contracts/network-runtime-plan'; import { inventoryUse } from '@agent-device/contracts/platform-module'; import { - appsRuntimeUse, + alertRuntimePlanUses, + appEventRuntimeUse, appStateRuntimeUses, + appSwitcherRuntimeUse, + appsRuntimeUse, backRuntimeUse, clickRuntimeUses, + clipboardRuntimePlanUses, deviceBootRuntimeUses, fillRuntimeUses, findRuntimePlanUses, @@ -41,12 +45,6 @@ import { gestureViewportRuntimeUse, homeRuntimeUse, hoverRuntimeUses, - appEventRuntimeUse, - settingsRuntimeUse, - alertRuntimePlanUses, - appSwitcherRuntimeUse, - tapPointUse, - clipboardRuntimePlanUses, keyboardRuntimePlanUses, longPressRuntimeUses, orientationRuntimeUse, @@ -54,11 +52,15 @@ import { pressRuntimeUses, screenshotRuntimePlanUses, scrollRuntimePlanUses, - swipeRuntimePlanUses, + selectFindStepUses, + selectSnapshotStepUses, selectorCaptureRuntimePlanUses, selectorTextCaptureRuntimePlanUses, + settingsRuntimeUse, shutdownTargetUse, snapshotRuntimePlanUses, + swipeRuntimePlanUses, + tapPointUse, tvRemoteRuntimeUse, typeTextRuntimeUse, viewportRuntimeUse, @@ -1016,7 +1018,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ // widens the envelope, and a timeout must not tear down the daemon. timeoutPolicy: { ...PRESERVE_DAEMON_TIMEOUT_POLICY, budget: { source: 'flag' } }, batchable: true, - platformExecution: { kind: 'device-runtime', uses: snapshotRuntimePlanUses }, + platformExecution: { + kind: 'device-runtime', + uses: snapshotRuntimePlanUses, + selectUses: selectSnapshotStepUses, + }, }, { name: 'diff', @@ -1029,7 +1035,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: { kind: 'device-runtime', uses: snapshotRuntimePlanUses }, + platformExecution: { + kind: 'device-runtime', + uses: snapshotRuntimePlanUses, + selectUses: selectSnapshotStepUses, + }, }, { name: 'wait', @@ -1165,7 +1175,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY, batchable: true, - platformExecution: { kind: 'device-runtime', uses: findRuntimePlanUses }, + platformExecution: { + kind: 'device-runtime', + uses: findRuntimePlanUses, + selectUses: selectFindStepUses, + }, }, // -- interaction (route: interaction) -- diff --git a/src/daemon/__tests__/execution-plan.test.ts b/src/daemon/__tests__/execution-plan.test.ts index 91793aeb38..822431aa64 100644 --- a/src/daemon/__tests__/execution-plan.test.ts +++ b/src/daemon/__tests__/execution-plan.test.ts @@ -6,18 +6,20 @@ import type { DaemonRequest } from '../types.ts'; test('an open that ends its batch has an unknown future', () => { assert.equal(resolveOpenApplicationPlan(undefined), undefined); - assert.equal(resolveOpenApplicationPlan({ remainingCommands: [] }), undefined); + assert.equal(resolveOpenApplicationPlan({ remainingSteps: [] }), undefined); }); test('remaining observation steps resolve to their declared operations', () => { - const plan = resolveOpenApplicationPlan({ remainingCommands: ['snapshot', 'wait'] }); + const plan = resolveOpenApplicationPlan({ + remainingSteps: [{ command: 'snapshot' }, { command: 'wait' }], + }); assert.ok(plan); assert.ok(plan.operations.includes('captureSnapshot')); assert.ok(!plan.operations.some((operation) => /^tap/.test(operation))); }); test('batch steps carry the commands still ahead of them through the internal channel', async () => { - const seen: Array<{ command: string; remaining: readonly string[] | undefined }> = []; + const seen: Array<{ command: string; remaining: unknown }> = []; const req: DaemonRequest = { token: 't', session: 'session', @@ -28,14 +30,14 @@ test('batch steps carry the commands still ahead of them through the internal ch const response = await runBatchCommands(req, 'session', async (stepRequest) => { seen.push({ command: stepRequest.command, - remaining: stepRequest.internal?.executionPlan?.remainingCommands, + remaining: stepRequest.internal?.executionPlan?.remainingSteps, }); return { ok: true, data: {} }; }); assert.equal(response.ok, true); assert.deepEqual(seen, [ - { command: 'open', remaining: ['snapshot'] }, + { command: 'open', remaining: [{ command: 'snapshot' }] }, { command: 'snapshot', remaining: [] }, ]); }); diff --git a/src/daemon/execution-plan.ts b/src/daemon/execution-plan.ts index 0960ec50e6..22ef167548 100644 --- a/src/daemon/execution-plan.ts +++ b/src/daemon/execution-plan.ts @@ -1,5 +1,8 @@ import type { OpenApplicationPlan } from '@agent-device/contracts/application-lifecycle-runtime'; -import { resolvePlannedRuntimeOperations } from '../core/command-descriptor/planned-operations.ts'; +import { + resolvePlannedRuntimeOperations, + type PlannedStep, +} from '../core/command-descriptor/planned-operations.ts'; /** * The part of a multi-step plan (a `batch`) that is still ahead of the request being executed. @@ -7,7 +10,7 @@ import { resolvePlannedRuntimeOperations } from '../core/command-descriptor/plan * which the transport strips from every wire request, so a remote client cannot steer platform * readiness policy with it and ADR 0006 stays untouched. */ -export type ExecutionPlan = Readonly<{ remainingCommands: readonly string[] }>; +export type ExecutionPlan = Readonly<{ remainingSteps: readonly PlannedStep[] }>; /** * The declared runtime operations of the steps still ahead of an `open`, or `undefined` when the @@ -17,7 +20,7 @@ export type ExecutionPlan = Readonly<{ remainingCommands: readonly string[] }>; export function resolveOpenApplicationPlan( plan: ExecutionPlan | undefined, ): OpenApplicationPlan | undefined { - if (plan === undefined || plan.remainingCommands.length === 0) return undefined; - const operations = resolvePlannedRuntimeOperations(plan.remainingCommands); + if (plan === undefined || plan.remainingSteps.length === 0) return undefined; + const operations = resolvePlannedRuntimeOperations(plan.remainingSteps); return operations === undefined ? undefined : { operations }; } diff --git a/src/daemon/handlers/session-batch.ts b/src/daemon/handlers/session-batch.ts index 4585745cfa..f50cf99a88 100644 --- a/src/daemon/handlers/session-batch.ts +++ b/src/daemon/handlers/session-batch.ts @@ -12,7 +12,7 @@ export async function runBatchCommands( ...step, internal: { ...step.internal, - executionPlan: { remainingCommands: context.remainingCommands }, + executionPlan: { remainingSteps: context.remainingSteps }, }, }); }); From 0673fcda63953e43298686a19f8b813c317e59d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 07:38:33 +0200 Subject: [PATCH 05/21] fix(ios): a registered runner session counts as live only once it has answered A session record exists while xcodebuild is still connecting, so an alive child pid is not a runner that can answer. Treating it as live sent the relaunch target reset into a starting runner, queued behind its connection retries, and the failed reset invalidated the very session the prewarm was building. Liveness now also requires the session's readiness flag, which the first successful runner response sets. --- .../src/runner/__tests__/runner-session.test.ts | 1 + packages/platform-apple/src/runner/runner-client.ts | 11 ++++++++--- packages/platform-apple/src/runner/runner-session.ts | 4 +++- .../__tests__/request-recording-health.test.ts | 1 + .../request-router-recording-health.test.ts | 1 + .../__tests__/session-device-resolution.test.ts | 12 ++++++++++-- 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts index 90e38562ec..d04f57e4ac 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts @@ -669,6 +669,7 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv assert.deepEqual(getRunnerSessionSnapshot(device.id), { sessionId: session.sessionId, alive: true, + ready: session.ready, }); }); diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index 1c2147a090..b47b0043bf 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -178,8 +178,10 @@ function resolveAppleRunnerRuntime( } /** - * Whether asking this device's runner now would wait for a startup. Observation paths use it to - * stay runner-free while no session is alive; a runner that is already up keeps answering. + * Whether asking this device's runner now would be answered without a startup wait. A session + * that is registered but has not answered yet is still starting, so it does not count: sending it + * a command would queue behind its connection retries, and a failed reset would even invalidate it. + * Observation paths use this to stay runner-free until the runner is ready. */ export function hasLiveIosRunnerSession( device: DeviceInfo, @@ -192,7 +194,10 @@ export function hasLiveIosRunnerSession( const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, { prepare: prepareLocalIosRunner, - hasLiveSession: (device) => getRunnerSessionSnapshot(device.id)?.alive === true, + hasLiveSession: (device) => { + const session = getRunnerSessionSnapshot(device.id); + return session !== null && session.alive && session.ready; + }, prewarm: async (device, options) => { const { healthCheck, ...runnerOptions } = options; if (healthCheck === false) { diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index 8dcd55c893..a65797d36c 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -421,12 +421,14 @@ function isBenignSimulatorRunnerUninstallResult(result: ExecResult): boolean { export function getRunnerSessionSnapshot( deviceId: string, -): { sessionId: string; alive: boolean } | null { +): { sessionId: string; alive: boolean; ready: boolean } | null { const session = runnerSessions.get(deviceId); if (!session) return null; return { sessionId: session.sessionId, alive: isRunnerProcessAlive(session.child.pid), + // A registered session whose runner has not answered yet is still starting. + ready: session.ready, }; } diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index ce7c9c3096..2a653fb3f2 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -51,6 +51,7 @@ test('runner-backed iOS recordings still invalidate on runner restarts', async ( mockGetRunnerSessionSnapshot.mockResolvedValue({ alive: true, sessionId: 'runner-after', + ready: true, }); await refreshRecordingHealth(session); diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index bf8011a66e..5bb15feefb 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -107,6 +107,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af mockGetRunnerSessionSnapshot.mockResolvedValue({ alive: true, sessionId: 'runner-after', + ready: true, }); const handler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), diff --git a/src/daemon/__tests__/session-device-resolution.test.ts b/src/daemon/__tests__/session-device-resolution.test.ts index 2aeaeb8240..8f780453a0 100644 --- a/src/daemon/__tests__/session-device-resolution.test.ts +++ b/src/daemon/__tests__/session-device-resolution.test.ts @@ -109,7 +109,11 @@ test('refreshSessionDeviceIfNeeded keeps provider-owned iOS simulators out of lo }); test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session is alive', async () => { - mockGetRunnerSessionSnapshot.mockResolvedValue({ sessionId: 'sim-1:1234:1', alive: true }); + mockGetRunnerSessionSnapshot.mockResolvedValue({ + sessionId: 'sim-1:1234:1', + alive: true, + ready: true, + }); const device = await withMockedPlatform('darwin', async () => refreshSessionDeviceIfNeeded(iosSimulatorSession.device), @@ -120,7 +124,11 @@ test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session }); test('refreshSessionDeviceIfNeeded re-resolves when the iOS runner session is gone', async () => { - mockGetRunnerSessionSnapshot.mockResolvedValue({ sessionId: 'sim-1:1234:1', alive: false }); + mockGetRunnerSessionSnapshot.mockResolvedValue({ + sessionId: 'sim-1:1234:1', + alive: false, + ready: false, + }); const resolved = { ...iosSimulatorSession.device, booted: true, name: 'renamed' }; mockResolveTargetDevice.mockResolvedValue(resolved); From 04b83810c865930404fbb6ee12cb80f4a04e463d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 07:40:56 +0200 Subject: [PATCH 06/21] refactor(ios): lift the bridge launch grace out of the snapshot route capture --- packages/platform-apple/src/snapshot-route.ts | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index db92be1152..e87e9687df 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -22,6 +22,7 @@ import { createSimulatorSnapshotSource, type SimulatorSnapshotSource, type SnapshotSourceFailure, + type SnapshotSourceOutcome, } from './snapshot-source-facade.ts'; import { createSimulatorSnapshotTargetResolver, @@ -98,24 +99,11 @@ export function createAppleSnapshotRoute( } const request = requestFor(input); - const acquire = async () => - await source.acquire({ target, hint: deriveIosCaptureHint(request), signal }); - let outcome = await acquire(); - if (outcome.stage === 'failed') { - const graceMs = launchGraceFor(outcome.failure, target, host.clock.now()); - const deadline = host.clock.now() + graceMs; - while ( - outcome.stage === 'failed' && - LAUNCH_GRACE_BY_CODE.has(outcome.failure.code) && - host.clock.now() < deadline - ) { - emitRouteDiagnostic('launch-grace-retry', device, target.generation, undefined, { - code: outcome.failure.code, - }); - await host.clock.sleep(LAUNCH_GRACE_POLL_MS, signal); - outcome = await acquire(); - } - } + const outcome = await acquireWithinLaunchGrace(source, host.clock, device, target, { + target, + hint: deriveIosCaptureHint(request), + signal, + }); if (outcome.stage === 'failed') { if (outcome.failure.kind === 'cancelled') { signal.throwIfAborted(); @@ -267,6 +255,31 @@ async function resolveFailureFallbackIdentity( } } +/** One acquisition, re-read while the launch grace for its typed failure still holds. */ +async function acquireWithinLaunchGrace( + source: SimulatorSnapshotSource, + clock: PlatformRuntimeHost['clock'], + device: DeviceInfo, + target: SimulatorSnapshotTarget, + request: Parameters[0], +): Promise { + let outcome = await source.acquire(request); + if (outcome.stage !== 'failed') return outcome; + const deadline = clock.now() + launchGraceFor(outcome.failure, target, clock.now()); + while ( + outcome.stage === 'failed' && + LAUNCH_GRACE_BY_CODE.has(outcome.failure.code) && + clock.now() < deadline + ) { + emitRouteDiagnostic('launch-grace-retry', device, target.generation, undefined, { + code: outcome.failure.code, + }); + await clock.sleep(LAUNCH_GRACE_POLL_MS, request.signal); + outcome = await source.acquire(request); + } + return outcome; +} + function launchGraceFor( failure: SnapshotSourceFailure, target: SimulatorSnapshotTarget, From 710e70aa97ad92748850403363c8d2d77ddf7c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 07:49:55 +0200 Subject: [PATCH 07/21] test(descriptors): pin the snapshot, diff, and find step-use selectors --- .../__tests__/find-runtime-execution.test.ts | 7 ++++++- .../__tests__/snapshot-runtime-execution.test.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts index 5e8f601f3a..da2dc3f8c8 100644 --- a/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts @@ -1,14 +1,19 @@ import { expect, test } from 'vitest'; -import { findRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; +import { + findRuntimePlanUses, + selectFindStepUses, +} from '@agent-device/contracts/platform-runtime-operations'; import { commandDescriptors } from '../registry.ts'; test('find descriptor declares its complete runtime uses with no legacy projection', () => { const find = commandDescriptors.find(({ name }) => name === 'find'); expect(find).not.toHaveProperty('capability'); + // Plan-time consumers select the alternative from the step input the way the handler does. expect(find?.platformExecution).toEqual({ kind: 'device-runtime', uses: findRuntimePlanUses, + selectUses: selectFindStepUses, }); // The complete direct-execution surface, action-selected so the handler binds exactly once // (ADR 0019 §9): the read-only element-text plans shared with `get`, the plain capture pair diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts index 6461897923..0800b0f319 100644 --- a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -1,4 +1,7 @@ -import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; +import { + selectSnapshotStepUses, + snapshotRuntimePlanUses, +} from '@agent-device/contracts/platform-runtime-operations'; import { expect, test } from 'vitest'; import { commandDescriptors } from '../registry.ts'; @@ -7,9 +10,11 @@ test('snapshot descriptor declares its complete planned capture uses with no leg expect(snapshot).not.toHaveProperty('capability'); expect(snapshot).not.toHaveProperty('dispatch'); + // Plan-time consumers select the alternative from the step input the way the handler does. expect(snapshot?.platformExecution).toEqual({ kind: 'device-runtime', uses: snapshotRuntimePlanUses, + selectUses: selectSnapshotStepUses, }); expect(snapshotRuntimePlanUses.map(({ required }) => required)).toEqual([ ['captureSnapshot'], @@ -27,5 +32,6 @@ test('diff descriptor reuses the complete snapshot plan uses with no legacy proj expect(diff?.platformExecution).toEqual({ kind: 'device-runtime', uses: snapshotRuntimePlanUses, + selectUses: selectSnapshotStepUses, }); }); From 2cb58c14d2d9830e2cb4cc4261beeb7bdccbc8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 08:24:26 +0200 Subject: [PATCH 08/21] test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet A Simulator open schedules a best-effort runner prewarm that outlives its request. The replay test-runner suite opened a Simulator with the real Apple tools, so the prewarm's deferred import resolved after the file finished and spawned into whichever file the worker ran next, where the hermetic signal guard failed an unrelated test. --- .../src/runner/__tests__/runner-session.test.ts | 7 ++----- .../__tests__/session-test-runner.test.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts index d04f57e4ac..3b1596492d 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts @@ -666,11 +666,8 @@ test('runner session starts xcodebuild through provider seams and reuses an aliv mockRunXcrun.mock.calls.some((call) => call[0]?.includes('uninstall')), false, ); - assert.deepEqual(getRunnerSessionSnapshot(device.id), { - sessionId: session.sessionId, - alive: true, - ready: session.ready, - }); + const expected = { sessionId: session.sessionId, alive: true, ready: false }; + assert.deepEqual(getRunnerSessionSnapshot(device.id), expected); }); test('runner session emits XCTest startup progress only after a runner rebuild', async () => { diff --git a/src/daemon/replay/internal/__tests__/session-test-runner.test.ts b/src/daemon/replay/internal/__tests__/session-test-runner.test.ts index 3539bed3b2..25ae69e255 100644 --- a/src/daemon/replay/internal/__tests__/session-test-runner.test.ts +++ b/src/daemon/replay/internal/__tests__/session-test-runner.test.ts @@ -14,6 +14,22 @@ import type { DaemonRequest } from '../../../types.ts'; import { handleSessionCommands } from '../../../handlers/__tests__/session-command-harness.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +// Opening a Simulator schedules a best-effort runner prewarm that outlives the request; a real one +// would spawn xcodebuild after this file finished and land in whichever file the worker runs next. +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + prewarmAppleRunnerCache: vi.fn(), + prewarmIosRunnerSession: vi.fn(), + notifyIosRunnerAppRelaunched: vi.fn(async () => {}), + hasLiveIosRunnerSession: vi.fn(() => false), + scheduleIosRunnerIdleStop: vi.fn(), + stopIosRunnerSession: vi.fn(async () => {}), + }; +}); + test('session_list includes device_udid and ios_simulator_device_set for iOS sessions', async () => { const sessionStore = makeSessionStore(); sessionStore.set( From fea26bfcf1e750b83a92a1d0840ea96fba90c23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:19:01 +0200 Subject: [PATCH 09/21] fix(plan): count only required operations and read find and snapshot steps the way their handlers do Runner demand now counts a command's required operations only: a preferred or conditional operation is a measured fast path the command succeeds without, so get, wait, and read-only find stay observation-only. The step selectors for snapshot, diff, and find live next to the registry and read the daemon step exactly as the handlers do: the daemon flag for custom actions, and find's positionals through the same parser, where a missing action is a click and an unparseable step keeps every declared alternative. The handler and the selector share one action-to-intent map. The batch runner hands each step its remaining steps in handler shape, and the derived operations reach the platform as a typed list on the lifecycle execution instead of an untyped plan on every open. --- .../src/application-lifecycle-runtime.ts | 27 ++++---- .../src/command-platform-execution.ts | 30 ++++---- .../src/platform-runtime-operations.ts | 42 ++++------- packages/platform-apple/src/lifecycle.test.ts | 12 ++-- packages/platform-apple/src/lifecycle.ts | 2 +- .../platform-apple/src/runner-demand.test.ts | 32 ++++----- packages/platform-apple/src/runner-demand.ts | 23 +++---- src/core/__tests__/batch.test.ts | 12 +++- src/core/batch.ts | 5 ++ .../__tests__/find-runtime-execution.test.ts | 6 +- .../__tests__/planned-operations.test.ts | 69 +++++++++++-------- .../snapshot-runtime-execution.test.ts | 6 +- .../command-descriptor/planned-operations.ts | 37 +++++----- src/core/command-descriptor/registry.ts | 3 +- .../command-descriptor/step-use-selectors.ts | 49 +++++++++++++ src/daemon/__tests__/execution-plan.test.ts | 30 ++++---- src/daemon/application-lifecycle-execution.ts | 2 + src/daemon/execution-plan.ts | 11 ++- src/daemon/interaction/internal/find.ts | 7 +- .../internal/session-open-execution.ts | 2 - 20 files changed, 229 insertions(+), 178 deletions(-) create mode 100644 src/core/command-descriptor/step-use-selectors.ts diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 93d966c876..7da2fbb06e 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -1,3 +1,5 @@ +import type { RuntimeOperationKey } from './platform-runtime.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; @@ -39,6 +41,13 @@ export type ApplicationLifecycleExecution = Readonly<{ iosXctestDerivedDataPath?: string; iosXctestEnvDir?: string; runnerLeaseContext?: RunnerLogicalLeaseContext; + /** + * The runtime operations the steps still ahead of this request inside the same plan (today: the + * remaining steps of a `batch`) must execute, derived by the daemon from the command descriptors' + * declared runtime uses. Never a public flag and never on the wire. Absent when the future of the + * session is unknown (a standalone command). + */ + plannedOperations?: readonly RuntimeOperationKey[]; }>; /** Semantic target resolution used before an application open. */ @@ -66,20 +75,13 @@ export type OpenApplicationPreparationInput = Readonly<{ execution: ApplicationLifecycleExecution; }>; -/** - * The declared runtime operations of the steps known to follow this open inside the same plan - * (today: the remaining steps of a `batch`). The daemon derives it from the command descriptors' - * declared runtime uses; it is never a public flag and never crosses the wire. Absent when the - * future of the session is unknown (a standalone `open`). - */ -export type OpenApplicationPlan = Readonly<{ operations: readonly string[] }>; - /** * How much the platform's interaction host (the XCTest runner on iOS) is known to be needed by - * the plan that contains an open. `none`: every following step is proven observation-only, so no - * runner is started or retained. `possible`: the plan is unknown, so a speculative prewarm may run - * but observation never awaits it. `required`: a following step needs the runner, so readiness is - * prepared now and awaited by that step. + * the plan that contains an open. `none`: every following step is proven observation-only, so this + * open starts no runner (an already-live runner stays under the existing idle-stop policy). + * `possible`: the plan is unknown, so a speculative prewarm may run but observation never awaits + * it. `required`: a following step needs the runner, so readiness is prepared now and awaited by + * that step. */ export type OpenApplicationRunnerDemand = 'none' | 'possible' | 'required'; @@ -94,7 +96,6 @@ export type OpenApplicationInput = Readonly<{ hasExistingSession: boolean; relaunch: boolean; prewarmRunnerBeforeOpen: boolean; - plan?: OpenApplicationPlan; enableTestIme: boolean; stateDir: string; runtimeHints: RuntimeHintValues; diff --git a/packages/contracts/src/command-platform-execution.ts b/packages/contracts/src/command-platform-execution.ts index c931059a4d..3922e69779 100644 --- a/packages/contracts/src/command-platform-execution.ts +++ b/packages/contracts/src/command-platform-execution.ts @@ -2,14 +2,20 @@ import type { InventoryUse } from './platform-module.ts'; import type { RuntimeUseDeclaration } from './platform-runtime.ts'; import { runtimeUseIdentity } from './platform-runtime-use.ts'; +/** A step as the daemon batch runner holds it: the same shape its handler will read. */ +export type RuntimeUseStep = Readonly<{ + positionals?: readonly string[]; + flags?: Readonly>; + input?: Readonly>; +}>; + /** - * Plan-time selection of the runtime uses one structured step input can reach, for a command whose - * declared alternatives differ in what they execute. Session-dependent splits (active app) stay - * open, so a selector returns every alternative the input still admits. + * Plan-time selection of the runtime uses one step can reach, for a command whose declared + * alternatives differ in what they execute. A selector reads the step the way the handler does + * and fails closed to every alternative when it cannot tell. Session-dependent splits (active + * app) stay open, so a selector returns every alternative the step still admits. */ -export type RuntimeUseStepSelector = ( - input: Readonly> | undefined, -) => readonly RuntimeUseDeclaration[]; +export type RuntimeUseStepSelector = (step: RuntimeUseStep) => readonly RuntimeUseDeclaration[]; export type CommandPlatformExecution = | Readonly<{ kind: 'none' }> @@ -53,15 +59,9 @@ export function assertCommandPlatformExecution( } if ( declaration['kind'] === 'device-runtime' && - sameKeys(keys, ['kind', 'uses']) && - hasRuntimeUseDeclarations(declaration['uses']) - ) { - return; - } - if ( - declaration['kind'] === 'device-runtime' && - sameKeys(keys, ['kind', 'selectUses', 'uses']) && - typeof declaration['selectUses'] === 'function' && + (sameKeys(keys, ['kind', 'uses']) || + (sameKeys(keys, ['kind', 'selectUses', 'uses']) && + typeof declaration['selectUses'] === 'function')) && hasRuntimeUseDeclarations(declaration['uses']) ) { return; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3ba1d459b6..be957530dc 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -54,7 +54,6 @@ import { type DeviceRuntimeOwner, type RuntimeOwnerRef, type RuntimePlatformModule, - type RuntimeUseDeclaration, } from './platform-runtime.ts'; import { runtimeUse } from './platform-runtime-use.ts'; import type { AndroidToolHost } from './platform-runtime-host.ts'; @@ -485,6 +484,18 @@ const selectorUsesByIntent = Object.freeze({ export type SelectorCaptureRuntimeIntent = keyof typeof selectorUsesByIntent; +/** + * The one action-selected plan `find` binds (ADR 0019 §9): the target capture always, plus the + * focus leg for `find focus` and the focus+type legs for `find type`. Every other action resolves + * its target from the capture and delegates or reads; the handler and plan-time consumers share + * this map so they cannot drift. + */ +export function findRuntimeIntent( + action: string, +): Extract { + return action === 'focus' ? 'find-focus' : action === 'type' ? 'find-type' : 'capture-only'; +} + /** * Same two `kind`s the snapshot plan uses for this split — deliberately, so capture-only, * element-text, and wait-observation callers share one admit-then-bind path. @@ -598,35 +609,6 @@ export function resolveSnapshotRuntimePlan(input: { }); } -/** - * The snapshot uses a structured `snapshot`/`diff` step reaches, read the way the handler reads its - * plan: custom actions select the XCTest-only alternative, and the active-app split stays open. - */ -export function selectSnapshotStepUses( - input: Readonly> | undefined, -): readonly RuntimeUseDeclaration[] { - const customActions = input?.['customActions'] === true; - return [true, false].map( - (hasActiveApp) => resolveSnapshotRuntimePlan({ customActions, hasActiveApp }).use, - ); -} - -/** - * The selector uses a structured `find` step reaches, mirroring the handler's action-selected - * plan: `focus` and `type` bind their combined leg, a text read binds the element-text capture, - * and an action with a delegated leg keeps every declared alternative. - */ -export function selectFindStepUses( - input: Readonly> | undefined, -): readonly RuntimeUseDeclaration[] { - const action = input?.['action']; - if (action === undefined || action === 'wait') return selectorUsesByIntent['capture-only']; - if (action === 'getText' || action === 'getAttrs') return selectorUsesByIntent['element-text']; - if (action === 'focus') return selectorUsesByIntent['find-focus']; - if (action === 'type') return selectorUsesByIntent['find-type']; - return findRuntimePlanUses; -} - const captureScreenshotUse = defineUse({ required: ['captureScreenshot'] }); /** * Screenshot post-processing that resolves a snapshot taken in the same request — `--overlay-refs` diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 200a7f33a1..ee90464794 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -319,7 +319,7 @@ test('a Simulator open whose plan is observation-only starts no runner and repor const outcome = await lifecycle.openApplication({ ...openInput(), - plan: { operations: ['captureSnapshot', 'findText', 'captureScreenshot'] }, + execution: { plannedOperations: ['captureSnapshot', 'findText', 'captureScreenshot'] }, }); expect(outcome.timing.runnerDemand).toBe('none'); @@ -331,7 +331,7 @@ test('a Simulator open whose plan is observation-only starts no runner and repor test.each([ ['an unknown plan', undefined, 'possible'], - ['a plan that needs the runner', { operations: ['captureSnapshot', 'tapPoint'] }, 'required'], + ['a plan that needs the runner', ['captureSnapshot', 'tapPoint'], 'required'], ] as const)( 'a Simulator relaunch with %s schedules the runner prewarm without awaiting it', async (_name, plan, expectedDemand) => { @@ -352,7 +352,11 @@ test.each([ signal: new AbortController().signal, }); - const opened = lifecycle.openApplication({ ...openInput(), relaunch: true, plan }); + const opened = lifecycle.openApplication({ + ...openInput(), + relaunch: true, + execution: { plannedOperations: plan }, + }); const outcome = await Promise.race([ opened, new Promise<'awaited-runner-readiness'>((resolve) => @@ -422,7 +426,7 @@ test('a physical iOS relaunch still awaits the runner prewarm and ignores the pl const outcome = await lifecycle.openApplication({ ...openInput(), - plan: { operations: ['captureSnapshot'] }, + execution: { plannedOperations: ['captureSnapshot'] }, }); expect(outcome.timing.runnerDemand).toBeUndefined(); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 1cb0ea9456..6bc3d868c1 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -104,7 +104,7 @@ function resolveRunnerPrewarmPolicy( localIosSimulator: boolean, ): RunnerPrewarmPolicy { const runnerDemand = localIosSimulator - ? resolveAppleSimulatorRunnerDemand(input.plan) + ? resolveAppleSimulatorRunnerDemand(input.execution.plannedOperations) : undefined; const shouldPrewarmRunner = isIosFamily(device) && diff --git a/packages/platform-apple/src/runner-demand.test.ts b/packages/platform-apple/src/runner-demand.test.ts index 7b9ad28d7f..d1b7e30156 100644 --- a/packages/platform-apple/src/runner-demand.test.ts +++ b/packages/platform-apple/src/runner-demand.test.ts @@ -7,17 +7,15 @@ test('an unknown plan keeps the speculative prewarm', () => { test('a plan served entirely by simctl and the AX bridge needs no runner', () => { expect( - resolveAppleSimulatorRunnerDemand({ - operations: [ - 'captureSnapshot', - 'captureSnapshotWithoutActiveApp', - 'captureScreenshot', - 'findText', - 'findSelector', - 'closeApplication', - 'finalizeApplicationClose', - ], - }), + resolveAppleSimulatorRunnerDemand([ + 'captureSnapshot', + 'captureSnapshotWithoutActiveApp', + 'captureScreenshot', + 'findText', + 'findSelector', + 'closeApplication', + 'finalizeApplicationClose', + ]), ).toBe('none'); }); @@ -27,14 +25,10 @@ test.each([ ['custom actions', 'captureSnapshotWithCustomActions'], ['an alert', 'readAlert'], ['runner preparation', 'prepareAppleRunner'], -])('a plan containing %s requires the runner', (_name, operation) => { - expect(resolveAppleSimulatorRunnerDemand({ operations: ['captureSnapshot', operation] })).toBe( - 'required', - ); +] as const)('a plan containing %s requires the runner', (_name, operation) => { + expect(resolveAppleSimulatorRunnerDemand(['captureSnapshot', operation])).toBe('required'); }); -test('an operation the table does not know is never proven runner-free', () => { - expect(resolveAppleSimulatorRunnerDemand({ operations: ['notARuntimeOperation'] })).toBe( - 'required', - ); +test('an empty plan is proven observation-only', () => { + expect(resolveAppleSimulatorRunnerDemand([])).toBe('none'); }); diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index f24d4de634..d9b40cbb72 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -1,7 +1,4 @@ -import type { - OpenApplicationPlan, - OpenApplicationRunnerDemand, -} from '@agent-device/contracts/application-lifecycle-runtime'; +import type { OpenApplicationRunnerDemand } from '@agent-device/contracts/application-lifecycle-runtime'; import type { RuntimeOperationKey } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; @@ -106,18 +103,14 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< /** * The runner demand of one local-Simulator open. An unknown plan keeps today's speculative - * prewarm; a plan whose operations are all simulator-served proves no runner is needed; any - * runner-served operation makes readiness worth preparing now. + * prewarm; a plan whose required operations are all simulator-served proves no runner is needed; + * any runner-served operation makes readiness worth preparing now. */ export function resolveAppleSimulatorRunnerDemand( - plan: OpenApplicationPlan | undefined, + operations: readonly RuntimeOperationKey[] | undefined, ): OpenApplicationRunnerDemand { - if (plan === undefined) return 'possible'; - const operations = plan.operations as readonly string[]; - const hosts = APPLE_SIMULATOR_OPERATION_HOSTS as Readonly< - Record - >; - // An operation this table does not know is not proven simulator-served. - if (operations.some((operation) => hosts[operation] !== 'simulator')) return 'required'; - return 'none'; + if (operations === undefined) return 'possible'; + return operations.some((operation) => APPLE_SIMULATOR_OPERATION_HOSTS[operation] === 'runner') + ? 'required' + : 'none'; } diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index 218cd3f1f8..881e7aa669 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -139,11 +139,19 @@ test('each step is invoked with its place in the plan and the steps still ahead assert.deepEqual(seen, [ { command: 'open', - remaining: [{ command: 'snapshot', input: { interactiveOnly: true } }, { command: 'click' }], + remaining: [ + { command: 'snapshot', positionals: [], flags: {}, input: { interactiveOnly: true } }, + { command: 'click', positionals: [], flags: {} }, + ], step: 1, total: 3, }, - { command: 'snapshot', remaining: [{ command: 'click' }], step: 2, total: 3 }, + { + command: 'snapshot', + remaining: [{ command: 'click', positionals: [], flags: {} }], + step: 2, + total: 3, + }, { command: 'click', remaining: [], step: 3, total: 3 }, ]); }); diff --git a/src/core/batch.ts b/src/core/batch.ts index 1086bbd4ae..faa7ff4c25 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -41,8 +41,11 @@ export type BatchRequest = Omit & { export type BatchStepContext = Readonly<{ stepNumber: number; totalSteps: number; + /** The steps still ahead, in the shape their handlers will read. */ remainingSteps: readonly Readonly<{ command: string; + positionals: readonly string[]; + flags: Readonly>; input?: Readonly>; }>[]; }>; @@ -106,6 +109,8 @@ export async function runBatch( totalSteps: steps.length, remainingSteps: steps.slice(index + 1).map((remaining) => ({ command: remaining.command, + positionals: remaining.positionals, + flags: remaining.flags, ...(remaining.input === undefined ? {} : { input: remaining.input }), })), }); diff --git a/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts index da2dc3f8c8..c73aee8f03 100644 --- a/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts @@ -1,8 +1,6 @@ import { expect, test } from 'vitest'; -import { - findRuntimePlanUses, - selectFindStepUses, -} from '@agent-device/contracts/platform-runtime-operations'; +import { findRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; +import { selectFindStepUses } from '../step-use-selectors.ts'; import { commandDescriptors } from '../registry.ts'; test('find descriptor declares its complete runtime uses with no legacy projection', () => { diff --git a/src/core/command-descriptor/__tests__/planned-operations.test.ts b/src/core/command-descriptor/__tests__/planned-operations.test.ts index 2ca061dd04..4a7e21c25b 100644 --- a/src/core/command-descriptor/__tests__/planned-operations.test.ts +++ b/src/core/command-descriptor/__tests__/planned-operations.test.ts @@ -3,48 +3,58 @@ import assert from 'node:assert/strict'; import { commandDescriptors } from '../registry.ts'; import { resolvePlannedRuntimeOperations } from '../planned-operations.ts'; -const steps = (...commands: string[]) => commands.map((command) => ({ command })); +const steps = (...commands: string[]) => + commands.map((command) => ({ command, positionals: [], flags: {} })); -test('observation commands declare only capture and selector-observation operations', () => { +test('observation commands require only capture and selector-observation operations', () => { const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'wait', 'is', 'screenshot')); assert.ok(operations); assert.ok(operations.includes('captureSnapshot')); - assert.ok(operations.includes('findText')); assert.ok(!operations.includes('captureSnapshotWithCustomActions')); for (const operation of operations) { assert.doesNotMatch(operation, /^(tap|fill|type|scroll|perform|hover|focus|longPress)/); } }); -test('a snapshot step selects the custom-actions alternative only when its input asks for it', () => { - const plain = resolvePlannedRuntimeOperations([{ command: 'snapshot', input: { depth: 2 } }]); +test('a preferred native read is a fast path, never a plan requirement', () => { + // `get` prefers the runner's point read but succeeds from the capture; the plan must not pull + // a runner in for it (ADR 0019: preferred is an optimization, required is the requirement). + const operations = resolvePlannedRuntimeOperations(steps('get')); + assert.ok(operations); + assert.ok(!operations.includes('readTextAtPoint')); + assert.ok(operations.includes('captureSnapshot')); +}); + +test('a snapshot step requires the custom-actions capture only when the daemon flag asks', () => { + const plain = resolvePlannedRuntimeOperations([ + { command: 'snapshot', positionals: [], flags: { depth: 2 } }, + ]); const custom = resolvePlannedRuntimeOperations([ - { command: 'snapshot', input: { customActions: true } }, + { command: 'snapshot', positionals: [], flags: { snapshotCustomActions: true } }, ]); assert.ok(plain && custom); assert.ok(!plain.includes('captureSnapshotWithCustomActions')); assert.ok(custom.includes('captureSnapshotWithCustomActions')); }); -test('a find step selects its leg from the action', () => { - const readOnly = resolvePlannedRuntimeOperations([{ command: 'find' }]); - const text = resolvePlannedRuntimeOperations([{ command: 'find', input: { action: 'getText' } }]); - const typed = resolvePlannedRuntimeOperations([{ command: 'find', input: { action: 'type' } }]); - const clicked = resolvePlannedRuntimeOperations([ - { command: 'find', input: { action: 'click' } }, - ]); - assert.ok(readOnly && text && typed && clicked); - assert.deepEqual( - readOnly.filter((operation) => /readTextAtPoint|focusPoint|typeText/.test(operation)), - [], - ); - assert.ok(text.includes('readTextAtPoint')); - assert.ok(typed.includes('typeText')); - // A delegated leg keeps every declared alternative rather than guessing. - assert.ok(clicked.includes('readTextAtPoint') && clicked.includes('typeText')); +test('a find step is planned from its positionals the way the handler parses them', () => { + const find = (...positionals: string[]) => + resolvePlannedRuntimeOperations([{ command: 'find', positionals, flags: {} }]); + const readOnly = find('text="Settings"', 'exists'); + const text = find('text="Settings"', 'get', 'text'); + const typed = find('text="Email"', 'type', 'hello'); + const defaultClick = find('text="Settings"'); + const unparsed = resolvePlannedRuntimeOperations([{ command: 'find', flags: {} }]); + assert.ok(readOnly && text && typed && defaultClick && unparsed); + assert.deepEqual(readOnly, ['captureSnapshot', 'captureSnapshotWithoutActiveApp']); + assert.deepEqual(text, ['captureSnapshot', 'captureSnapshotWithoutActiveApp']); + assert.ok(typed.includes('typeText') && typed.includes('focusPoint')); + // A missing action is a click, and a step that cannot be parsed keeps every alternative. + assert.ok(defaultClick.includes('focusPoint') && defaultClick.includes('typeText')); + assert.deepEqual(unparsed, defaultClick); }); -test('an interaction command contributes its touch operations', () => { +test('an interaction command contributes its required touch operations', () => { const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'click')); assert.ok(operations); assert.ok(operations.some((operation) => /^tap/.test(operation))); @@ -59,12 +69,13 @@ test('an unregistered command makes the plan unproven', () => { }); test('every step selector returns a non-empty subset of the alternatives its command declares', () => { - const probes: Array | undefined> = [ - undefined, - { customActions: true }, - { action: 'getText' }, - { action: 'type' }, - { action: 'click' }, + const probes = [ + { positionals: [], flags: {} }, + { positionals: [], flags: { snapshotCustomActions: true } }, + { positionals: ['text="x"', 'get', 'text'], flags: {} }, + { positionals: ['text="x"', 'type', 'y'], flags: {} }, + { positionals: ['text="x"'], flags: {} }, + { flags: {} }, ]; let selectors = 0; for (const descriptor of commandDescriptors) { diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts index 0800b0f319..8def61b6ac 100644 --- a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -1,7 +1,5 @@ -import { - selectSnapshotStepUses, - snapshotRuntimePlanUses, -} from '@agent-device/contracts/platform-runtime-operations'; +import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; +import { selectSnapshotStepUses } from '../step-use-selectors.ts'; import { expect, test } from 'vitest'; import { commandDescriptors } from '../registry.ts'; diff --git a/src/core/command-descriptor/planned-operations.ts b/src/core/command-descriptor/planned-operations.ts index b5b9153d49..9df94a16e8 100644 --- a/src/core/command-descriptor/planned-operations.ts +++ b/src/core/command-descriptor/planned-operations.ts @@ -1,3 +1,6 @@ +import type { RuntimeUseStep } from '@agent-device/contracts/command-platform-execution'; +import type { RuntimeOperationKey } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { commandDescriptors } from './registry.ts'; import type { CommandDescriptor } from './types.ts'; @@ -5,36 +8,34 @@ const descriptorsByName = new Map( commandDescriptors.map((descriptor) => [descriptor.name, descriptor]), ); -/** One step of a plan as the batch runner knows it: the command and its structured input. */ -export type PlannedStep = Readonly<{ - command: string; - input?: Readonly>; -}>; +/** One step of a plan as the batch runner holds it: the command plus what its handler will read. */ +export type PlannedStep = RuntimeUseStep & Readonly<{ command: string }>; + +export type PlannedRuntimeOperation = RuntimeOperationKey; /** - * The runtime operations a sequence of steps may execute, read from each command's declared - * platform execution (ADR 0019 §6). A command whose alternatives differ selects them from the - * step input the way its handler does (`selectUses`); every other command contributes all of its - * alternatives, and every declared category counts — required, preferred, and conditional — - * because a plan is proven observation-only only when no step can reach an interaction - * operation. Returns `undefined` when any command is unknown to the registry: an unregistered - * step makes the plan unproven rather than silently empty. + * The runtime operations a sequence of steps must execute, read from each command's declared + * platform execution (ADR 0019 §6). Only `required` operations count: a preferred or conditional + * operation is a measured fast path the command succeeds without, so it must not pull a runner + * into a plan that is otherwise observation-only. A command whose alternatives differ selects + * them from the step the way its handler does (`selectUses`); every other command contributes + * all of its alternatives. Returns `undefined` when any command is unknown to the registry: an + * unregistered step makes the plan unproven rather than silently empty. */ export function resolvePlannedRuntimeOperations( steps: readonly PlannedStep[], -): readonly string[] | undefined { - const operations = new Set(); +): readonly PlannedRuntimeOperation[] | undefined { + const operations = new Set(); for (const step of steps) { const descriptor = descriptorsByName.get(step.command); if (!descriptor) return undefined; const execution = descriptor.platformExecution; if (execution.kind !== 'device-runtime') continue; const uses = - 'uses' in execution - ? (execution.selectUses?.(step.input) ?? execution.uses) - : [execution.use]; + 'uses' in execution ? (execution.selectUses?.(step) ?? execution.uses) : [execution.use]; for (const use of uses) { - for (const operation of [...use.required, ...use.preferred, ...(use.conditional ?? [])]) { + // Every declared use is built with `defineUse`, which admits only operation keys. + for (const operation of use.required as readonly PlannedRuntimeOperation[]) { operations.add(operation); } } diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index d79be29f4b..2f4dca967d 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -52,8 +52,6 @@ import { pressRuntimeUses, screenshotRuntimePlanUses, scrollRuntimePlanUses, - selectFindStepUses, - selectSnapshotStepUses, selectorCaptureRuntimePlanUses, selectorTextCaptureRuntimePlanUses, settingsRuntimeUse, @@ -66,6 +64,7 @@ import { viewportRuntimeUse, waitSelectorCaptureRuntimePlanUses, } from '@agent-device/contracts/platform-runtime-operations'; +import { selectFindStepUses, selectSnapshotStepUses } from './step-use-selectors.ts'; import { assertRecordRuntimeExecution } from '@agent-device/contracts/record-runtime-execution'; import { screenRecordingRuntimePlanUses } from '@agent-device/contracts/screen-recording-runtime-plan'; import { readDeclaredPlatformExecution } from './platform-execution-entry.ts'; diff --git a/src/core/command-descriptor/step-use-selectors.ts b/src/core/command-descriptor/step-use-selectors.ts new file mode 100644 index 0000000000..c5b2272f04 --- /dev/null +++ b/src/core/command-descriptor/step-use-selectors.ts @@ -0,0 +1,49 @@ +import type { + RuntimeUseStep, + RuntimeUseStepSelector, +} from '@agent-device/contracts/command-platform-execution'; +import { + findRuntimeIntent, + findRuntimePlanUses, + resolveSelectorCaptureRuntimePlan, + resolveSnapshotRuntimePlan, +} from '@agent-device/contracts/platform-runtime-operations'; +import { checkFindArgs, isReadOnlyFindAction, type FindAction } from '@agent-device/selectors'; + +/** + * Plan-time selectors for the commands whose declared alternatives differ in what they execute. + * Each reads the daemon step exactly as its handler will (`flags` and `positionals`; a structured + * `input` only when a caller kept one) and resolves the same plan the handler resolves, for both + * sides of the active-app split the plan cannot know yet. + */ +export const selectSnapshotStepUses: RuntimeUseStepSelector = (step) => { + const customActions = + step.flags?.['snapshotCustomActions'] === true || step.input?.['customActions'] === true; + return [true, false].map( + (hasActiveApp) => resolveSnapshotRuntimePlan({ customActions, hasActiveApp }).use, + ); +}; + +/** + * `find` parses its action from positionals and defaults a missing one to click, so a step the + * handler would not parse as a read-only, focus, or type action keeps every declared alternative + * (fail closed), including a step whose positionals are not there to parse. + */ +export const selectFindStepUses: RuntimeUseStepSelector = (step) => { + const action = findStepAction(step); + if (action === undefined || !plansOwnLeg(action)) return findRuntimePlanUses; + const intent = findRuntimeIntent(action); + return [true, false].map( + (hasActiveApp) => resolveSelectorCaptureRuntimePlan({ hasActiveApp, intent }).use, + ); +}; + +function plansOwnLeg(action: FindAction['kind']): boolean { + return isReadOnlyFindAction(action) || action === 'focus' || action === 'type'; +} + +function findStepAction(step: RuntimeUseStep): FindAction['kind'] | undefined { + if (step.positionals === undefined) return undefined; + const checked = checkFindArgs(step.positionals, step.flags); + return checked.ok ? checked.parsed.action : undefined; +} diff --git a/src/daemon/__tests__/execution-plan.test.ts b/src/daemon/__tests__/execution-plan.test.ts index 822431aa64..dafc1d1a38 100644 --- a/src/daemon/__tests__/execution-plan.test.ts +++ b/src/daemon/__tests__/execution-plan.test.ts @@ -1,31 +1,34 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { resolveOpenApplicationPlan } from '../execution-plan.ts'; +import { resolvePlannedOperations } from '../execution-plan.ts'; import { runBatchCommands } from '../handlers/session-batch.ts'; import type { DaemonRequest } from '../types.ts'; test('an open that ends its batch has an unknown future', () => { - assert.equal(resolveOpenApplicationPlan(undefined), undefined); - assert.equal(resolveOpenApplicationPlan({ remainingSteps: [] }), undefined); + assert.equal(resolvePlannedOperations(undefined), undefined); + assert.equal(resolvePlannedOperations({ remainingSteps: [] }), undefined); }); -test('remaining observation steps resolve to their declared operations', () => { - const plan = resolveOpenApplicationPlan({ - remainingSteps: [{ command: 'snapshot' }, { command: 'wait' }], +test('remaining observation steps resolve to their required operations', () => { + const operations = resolvePlannedOperations({ + remainingSteps: [ + { command: 'snapshot', positionals: [], flags: {} }, + { command: 'wait', positionals: ['text', 'Ready'], flags: {} }, + ], }); - assert.ok(plan); - assert.ok(plan.operations.includes('captureSnapshot')); - assert.ok(!plan.operations.some((operation) => /^tap/.test(operation))); + assert.ok(operations); + assert.ok(operations.includes('captureSnapshot')); + assert.ok(!operations.some((operation) => /^tap/.test(operation))); }); -test('batch steps carry the commands still ahead of them through the internal channel', async () => { +test('batch steps carry the steps still ahead of them through the internal channel', async () => { const seen: Array<{ command: string; remaining: unknown }> = []; const req: DaemonRequest = { token: 't', session: 'session', command: 'batch', positionals: [], - flags: { batchSteps: [{ command: 'open' }, { command: 'snapshot' }] }, + flags: { batchSteps: [{ command: 'open' }, { command: 'snapshot', flags: { depth: 1 } }] }, }; const response = await runBatchCommands(req, 'session', async (stepRequest) => { seen.push({ @@ -37,7 +40,10 @@ test('batch steps carry the commands still ahead of them through the internal ch assert.equal(response.ok, true); assert.deepEqual(seen, [ - { command: 'open', remaining: [{ command: 'snapshot' }] }, + { + command: 'open', + remaining: [{ command: 'snapshot', positionals: [], flags: { depth: 1 } }], + }, { command: 'snapshot', remaining: [] }, ]); }); diff --git a/src/daemon/application-lifecycle-execution.ts b/src/daemon/application-lifecycle-execution.ts index a99c91e070..f80c5c32db 100644 --- a/src/daemon/application-lifecycle-execution.ts +++ b/src/daemon/application-lifecycle-execution.ts @@ -1,3 +1,4 @@ +import { resolvePlannedOperations } from './execution-plan.ts'; import type { ApplicationLifecycleExecution } from '@agent-device/contracts/application-lifecycle-runtime'; import { resolveRunnerLogicalLeaseContext } from './lease-context.ts'; import type { DaemonRequest } from './types.ts'; @@ -21,5 +22,6 @@ export function applicationLifecycleExecutionFromRequest( iosXctestDerivedDataPath: req.flags?.iosXctestDerivedDataPath, iosXctestEnvDir: req.flags?.iosXctestEnvDir, runnerLeaseContext: resolveRunnerLogicalLeaseContext(req), + plannedOperations: resolvePlannedOperations(req.internal?.executionPlan), }; } diff --git a/src/daemon/execution-plan.ts b/src/daemon/execution-plan.ts index 22ef167548..bcecd8c062 100644 --- a/src/daemon/execution-plan.ts +++ b/src/daemon/execution-plan.ts @@ -1,6 +1,6 @@ -import type { OpenApplicationPlan } from '@agent-device/contracts/application-lifecycle-runtime'; import { resolvePlannedRuntimeOperations, + type PlannedRuntimeOperation, type PlannedStep, } from '../core/command-descriptor/planned-operations.ts'; @@ -13,14 +13,13 @@ import { export type ExecutionPlan = Readonly<{ remainingSteps: readonly PlannedStep[] }>; /** - * The declared runtime operations of the steps still ahead of an `open`, or `undefined` when the + * The runtime operations the steps still ahead of an `open` must execute, or `undefined` when the * future is unknown. A plan with no remaining step is unknown too: an `open` that ends its batch * is a prelude to standalone commands the daemon cannot see yet. */ -export function resolveOpenApplicationPlan( +export function resolvePlannedOperations( plan: ExecutionPlan | undefined, -): OpenApplicationPlan | undefined { +): readonly PlannedRuntimeOperation[] | undefined { if (plan === undefined || plan.remainingSteps.length === 0) return undefined; - const operations = resolvePlannedRuntimeOperations(plan.remainingSteps); - return operations === undefined ? undefined : { operations }; + return resolvePlannedRuntimeOperations(plan.remainingSteps); } diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 78174ae904..ecaa7f0372 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -23,7 +23,10 @@ import { executeBoundTypeText } from '../../type-text-runtime.ts'; import { dispatchFindReadOnlyViaRuntime } from '../../selector-runtime.ts'; import { admitAndBindSnapshotCapture } from '../../snapshot-runtime-binding.ts'; import type { FocusPointInput } from '@agent-device/contracts/focus-runtime'; -import { resolveSelectorCaptureRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; +import { + findRuntimeIntent, + resolveSelectorCaptureRuntimePlan, +} from '@agent-device/contracts/platform-runtime-operations'; import type { TypeTextRuntimeOperations } from '@agent-device/contracts/type-text-runtime'; import type { FindRouteInput } from './types.ts'; import { createFindTargetCapture, sparseFindSnapshotResponse } from './find-target-capture.ts'; @@ -106,7 +109,7 @@ export async function handleFindCommands(params: FindRouteInput): Promise Date: Sun, 6 Sep 2026 10:24:11 +0200 Subject: [PATCH 10/21] perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit The snapshot route no longer infers a launch from process start text and retries inside its own capture. Open owns launch timing instead: a local Simulator open asks the AX bridge whether the launched app is observable, bounded by per-code windows measured from the first typed launch-transition failure and never extended, so an ownership miss seen after an AX-server miss shrinks the deadline to the ownership window and a launch-time system dialog still reaches the typed fallback quickly. Any other device, or a bridge that cannot answer, keeps the fixed settle. The open response reports what it learned. Every runner provider now states whether it can answer without a startup wait; a bare executor answers directly by construction and scripted providers say so. The runner prewarm policy and the observation settle move out of the open sequence into their own module, and the native find admission is named for what it admits. --- .../src/application-lifecycle-runtime.ts | 5 +- .../interactor-runner-provider.test.ts | 13 +- packages/platform-apple/src/lifecycle.test.ts | 50 ++++++ packages/platform-apple/src/lifecycle.ts | 65 ++----- packages/platform-apple/src/open-policy.ts | 76 +++++++++ .../runner-client-live-session.test.ts | 2 +- .../runner/__tests__/runner-provider.test.ts | 1 + .../src/runner/runner-client.ts | 3 +- .../src/runner/runner-provider.ts | 11 +- .../platform-apple/src/runtime-snapshot.ts | 14 +- packages/platform-apple/src/runtime.ts | 1 + .../src/snapshot-observability.test.ts | 151 +++++++++++++++++ .../src/snapshot-observability.ts | 72 ++++++++ .../platform-apple/src/snapshot-route.test.ts | 158 ------------------ packages/platform-apple/src/snapshot-route.ts | 85 ++-------- .../request-platform-providers.test.ts | 4 +- .../apple-platform-output-guard.test.ts | 1 + .../provider-scenarios/macos-desktop.test.ts | 1 + .../provider-ios-runner-transport.test.ts | 1 + .../provider-scenarios/providers.ts | 2 + 20 files changed, 418 insertions(+), 298 deletions(-) create mode 100644 packages/platform-apple/src/open-policy.ts create mode 100644 packages/platform-apple/src/snapshot-observability.test.ts create mode 100644 packages/platform-apple/src/snapshot-observability.ts diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 7da2fbb06e..dd74ca678a 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -1,10 +1,9 @@ -import type { RuntimeOperationKey } from './platform-runtime.ts'; +import type { RuntimeOperationFact, RuntimeOperationKey } from './platform-runtime.ts'; import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; import type { SessionSurface } from './session-surface.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { ProviderPortReverseOptions } from './provider-device-runtime.ts'; import type { TargetShutdownResult } from './target-shutdown-contract.ts'; @@ -120,6 +119,8 @@ export type OpenApplicationTiming = Readonly<{ openDispatchDurationMs?: number; launchUrlDurationMs?: number; postOpenSettleDurationMs?: number; + /** What a Simulator open learned about the launched app before returning (see the Apple owner). */ + postOpenObservation?: 'observable' | 'unobservable' | 'not-eligible'; }>; export type OpenApplicationOutcome = Readonly<{ diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index b3804a675e..b14a6cdddd 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -195,6 +195,7 @@ test('snapshot publishes runner presentation through the engine and drops its qu IOS_SIMULATOR, {}, { + hasLiveSession: () => true, runCommand: async () => ({ nodes: [ { @@ -270,7 +271,7 @@ test('macOS app snapshots preserve runner nodes outside the iOS presentation eng const interactor = createAppleInteractor( MACOS_DEVICE, {}, - { runCommand: async () => ({ nodes }) }, + { hasLiveSession: () => true, runCommand: async () => ({ nodes }) }, ); const result = presentedSnapshot(await interactor.snapshot({ interactiveOnly: true })); @@ -282,7 +283,10 @@ test('snapshot reports typed runner presentation failures', async () => { const interactor = createAppleInteractor( IOS_SIMULATOR, {}, - { runCommand: async () => ({ nodes: [{ index: 0, type: 'Application' }] }) }, + { + hasLiveSession: () => true, + runCommand: async () => ({ nodes: [{ index: 0, type: 'Application' }] }), + }, ); await assert.rejects(interactor.snapshot(), (error: unknown) => { @@ -298,6 +302,7 @@ test('sparse runner payloads with no viewport fail before publishing actionable IOS_SIMULATOR, {}, { + hasLiveSession: () => true, runCommand: async () => ({ nodes: [ { index: 0, type: 'Application', label: 'App' }, @@ -335,6 +340,7 @@ test('snapshot rejects a scoped quality payload at the runner boundary', async ( IOS_SIMULATOR, {}, { + hasLiveSession: () => true, runCommand: async () => ({ nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }], qualityPayload: { nodes: [], truncated: false, scope: 'Settings' }, @@ -353,6 +359,7 @@ test('snapshot rejects a scoped quality payload at the runner boundary', async ( test('snapshot accepts only structured healthy empty scope results', async () => { const healthyEmptyProvider: AppleRunnerProvider = { + hasLiveSession: () => true, runCommand: async () => ({ nodes: [], snapshotQuality: { state: 'healthy', backend: 'tree' }, @@ -376,6 +383,7 @@ test('snapshot accepts only structured healthy empty scope results', async () => IOS_SIMULATOR, {}, { + hasLiveSession: () => true, runCommand: async () => ({ nodes: [] }), }, ); @@ -403,6 +411,7 @@ test('snapshot forwards either forceable preferredBackend into the emitted runne function recordingRunnerProvider(calls: RecordedRunnerCall[]): AppleRunnerProvider { return { + hasLiveSession: () => true, runCommand: async (_device, command, options) => { calls.push({ command, options }); return runnerResultFor(command); diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index ee90464794..0a315047c9 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -433,3 +433,53 @@ test('a physical iOS relaunch still awaits the runner prewarm and ignores the pl expect(outcome.timing.runnerPrewarmWaited).toBe(true); expect(events).toEqual(['close', 'open', 'prewarm', 'reset']); }); + +test('a Simulator open lets the launched app become observable instead of sleeping a fixed settle', async () => { + const events: string[] = []; + const { host } = simulatorHost({ events }); + const sleep = vi.fn(async () => { + events.push('sleep'); + }); + const awaitObservable = vi.fn(async () => { + events.push('observe'); + return 'observable' as const; + }); + const signal = new AbortController().signal; + const lifecycle = bindAppleApplicationLifecycle({ + host: { ...host, clock: { ...host.clock, sleep } } as unknown as PlatformRuntimeHost, + device: simulator, + signal, + observation: { awaitObservable }, + }); + + const outcome = await lifecycle.openApplication({ + ...openInput(), + execution: { plannedOperations: ['captureSnapshot'] }, + }); + + expect(awaitObservable).toHaveBeenCalledWith(simulator, 'com.example.app', signal); + expect(outcome.timing.postOpenObservation).toBe('observable'); + expect(events).toEqual(['open', 'observe']); +}); + +test('a Simulator whose bridge cannot answer keeps the fixed settle', async () => { + const events: string[] = []; + const { host } = simulatorHost({ events }); + const sleep = vi.fn(async () => { + events.push('sleep'); + }); + const lifecycle = bindAppleApplicationLifecycle({ + host: { ...host, clock: { ...host.clock, sleep } } as unknown as PlatformRuntimeHost, + device: simulator, + signal: new AbortController().signal, + observation: { awaitObservable: async () => 'unobservable' as const }, + }); + + const outcome = await lifecycle.openApplication({ + ...openInput(), + execution: { plannedOperations: ['captureSnapshot'] }, + }); + + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(events).toEqual(['open', 'sleep']); +}); diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 6bc3d868c1..0a5ace4c78 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -5,7 +5,6 @@ import { type CloseApplicationInput, type OpenApplicationInput, type OpenApplicationOutcome, - type OpenApplicationRunnerDemand, type PrepareAppleRunnerInput, type PrepareAppleRunnerResult, hasRuntimeTransportHintValues, @@ -18,12 +17,16 @@ import { } from '@agent-device/contracts/application-lifecycle-interaction'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { ensureAppleReady } from './readiness/runtime.ts'; -import { resolveAppleSimulatorRunnerDemand } from './runner-demand.ts'; +import { + resolveRunnerPrewarmPolicy, + settleAppleOpen, + type MutableOpenTiming, +} from './open-policy.ts'; +import type { LaunchObservationPort } from './snapshot-observability.ts'; import { isApplePlatform, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; const POST_CLOSE_SETTLE_MS = 300; -const POST_OPEN_SETTLE_MS = 300; /** The Apple package receives only the lazy tools and readiness ports it owns. */ type AppleLifecycleHost = Pick< @@ -37,14 +40,12 @@ type AppleLifecycleHost = Pick< | 'localInteractors' >; -type MutableOpenTiming = { - -readonly [Key in keyof OpenApplicationOutcome['timing']]: OpenApplicationOutcome['timing'][Key]; -}; - type AppleLifecycleParams = Readonly<{ host: AppleLifecycleHost; device: DeviceInfo; signal: AbortSignal; + /** The Simulator bridge's launch observation, when the runtime binds one (local Simulators). */ + observation?: LaunchObservationPort; }>; /** Apple owns its lifecycle ordering; the root host exposes only lazy runner/tool ports. */ @@ -70,7 +71,8 @@ export function bindAppleApplicationLifecycle( : undefined, }); }, - openApplication: async (input) => await openAppleApplication(params.host, binding, input), + openApplication: async (input) => + await openAppleApplication(params.host, binding, input, params.observation), applyRuntimeHints: async (input) => await params.host.appleApplications.applyRuntimeHints(params.device, input), clearRuntimeHints: async (input) => @@ -86,43 +88,11 @@ export function bindAppleApplicationLifecycle( type BoundAppleInteractor = ReturnType; -type RunnerPrewarmPolicy = Readonly<{ - runnerDemand?: OpenApplicationRunnerDemand; - shouldPrewarmRunner: boolean; - awaitPrewarmAfterOpen: boolean; -}>; - -/** - * Only a local Simulator has a runner-free observation path (the host AX bridge), so only it - * consults the plan and never waits for runner readiness after the open: bridge observation does - * not need it, and the first runner-dependent command awaits the same startup under the runner - * session lock. Physical devices keep their runner lifecycle unchanged. - */ -function resolveRunnerPrewarmPolicy( - device: DeviceInfo, - input: OpenApplicationInput, - localIosSimulator: boolean, -): RunnerPrewarmPolicy { - const runnerDemand = localIosSimulator - ? resolveAppleSimulatorRunnerDemand(input.execution.plannedOperations) - : undefined; - const shouldPrewarmRunner = - isIosFamily(device) && - input.surface === 'app' && - input.positionals.length > 0 && - Boolean(input.appBundleId) && - runnerDemand !== 'none'; - return { - ...(runnerDemand ? { runnerDemand } : {}), - shouldPrewarmRunner, - awaitPrewarmAfterOpen: input.relaunch && !localIosSimulator, - }; -} - async function openAppleApplication( host: AppleLifecycleHost, binding: BoundAppleInteractor, input: OpenApplicationInput, + observation: LaunchObservationPort | undefined, ): Promise { const timing: MutableOpenTiming = {}; const localIosSimulator = isIosSimulator(binding.device); @@ -158,7 +128,7 @@ async function openAppleApplication( runnerTargetPredatesOpen, retainRunnerForRelaunch, ); - await settleAppleOpen(host, binding, localIosSimulator, timing); + await settleAppleOpen(host, binding, input, localIosSimulator, observation, timing); return { appBundleId: input.appBundleId, timing }; } catch (error) { if (retainRunnerForRelaunch) { @@ -306,17 +276,6 @@ async function notifyAppleRunnerRelaunch( ); } -async function settleAppleOpen( - host: AppleLifecycleHost, - binding: BoundAppleInteractor, - localIosSimulator: boolean, - timing: MutableOpenTiming, -): Promise { - const startedAtMs = Date.now(); - if (localIosSimulator) await host.clock.sleep(POST_OPEN_SETTLE_MS, binding.signal); - timing.postOpenSettleDurationMs = elapsed(startedAtMs); -} - function shouldCloseForAppleRelaunch( input: OpenApplicationInput, localIosSimulator: boolean, diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts new file mode 100644 index 0000000000..571e6756db --- /dev/null +++ b/packages/platform-apple/src/open-policy.ts @@ -0,0 +1,76 @@ +import type { + OpenApplicationInput, + OpenApplicationOutcome, + OpenApplicationRunnerDemand, +} from '@agent-device/contracts/application-lifecycle-runtime'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { resolveAppleSimulatorRunnerDemand } from './runner-demand.ts'; +import type { LaunchObservationPort } from './snapshot-observability.ts'; + +const POST_OPEN_SETTLE_MS = 300; + +export type MutableOpenTiming = { + -readonly [Key in keyof OpenApplicationOutcome['timing']]: OpenApplicationOutcome['timing'][Key]; +}; + +export type RunnerPrewarmPolicy = Readonly<{ + runnerDemand?: OpenApplicationRunnerDemand; + shouldPrewarmRunner: boolean; + awaitPrewarmAfterOpen: boolean; +}>; + +/** + * Only a local Simulator has a runner-free observation path (the host AX bridge), so only it + * consults the plan and never waits for runner readiness after the open: bridge observation does + * not need it, and the first runner-dependent command awaits the same startup under the runner + * session lock. Physical devices keep their runner lifecycle unchanged. + */ +export function resolveRunnerPrewarmPolicy( + device: DeviceInfo, + input: OpenApplicationInput, + localIosSimulator: boolean, +): RunnerPrewarmPolicy { + const runnerDemand = localIosSimulator + ? resolveAppleSimulatorRunnerDemand(input.execution.plannedOperations) + : undefined; + const shouldPrewarmRunner = + isIosFamily(device) && + input.surface === 'app' && + input.positionals.length > 0 && + Boolean(input.appBundleId) && + runnerDemand !== 'none'; + return { + ...(runnerDemand ? { runnerDemand } : {}), + shouldPrewarmRunner, + awaitPrewarmAfterOpen: input.relaunch && !localIosSimulator, + }; +} + +/** + * Lets the opened app become observable before the open returns. A local Simulator asks its AX + * bridge, bounded by the launch-transition windows the bridge itself defines, so the first + * observation never pays the launch and never falls back to a runner start for it. Any other + * device, or a Simulator whose bridge cannot answer, keeps the fixed settle. + */ +export async function settleAppleOpen( + host: Pick, + binding: Readonly<{ device: DeviceInfo; signal: AbortSignal }>, + input: OpenApplicationInput, + localIosSimulator: boolean, + observation: LaunchObservationPort | undefined, + timing: MutableOpenTiming, +): Promise { + const startedAtMs = Date.now(); + if (localIosSimulator && observation && input.appBundleId) { + timing.postOpenObservation = await observation.awaitObservable( + binding.device, + input.appBundleId, + binding.signal, + ); + } + if (localIosSimulator && timing.postOpenObservation !== 'observable') { + await host.clock.sleep(POST_OPEN_SETTLE_MS, binding.signal); + } + timing.postOpenSettleDurationMs = Math.max(0, Date.now() - startedAtMs); +} diff --git a/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts index 4e42005c6d..6a6545a25f 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client-live-session.test.ts @@ -17,7 +17,7 @@ test('the local runner is live only while its session registry holds an alive se expect(hasLiveIosRunnerSession(simulator)).toBe(false); }); -test('a scoped provider without startup cost counts as live', async () => { +test('a bare executor has no session to start and answers at once', async () => { await withAppleRunnerProvider( async () => ({}), { deviceId: simulator.id }, diff --git a/packages/platform-apple/src/runner/__tests__/runner-provider.test.ts b/packages/platform-apple/src/runner/__tests__/runner-provider.test.ts index 90e5861c75..c242b49c90 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-provider.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-provider.test.ts @@ -48,6 +48,7 @@ test('scoped Apple runner provider requires matching request id when scoped by r function runnerProvider(source: string, calls: string[]): AppleRunnerProvider { return { + hasLiveSession: () => true, runCommand: async () => { calls.push(source); return { source }; diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index b47b0043bf..79a0cca2ca 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -188,8 +188,7 @@ export function hasLiveIosRunnerSession( options: { requestId?: string } = {}, ): boolean { if (!isIosFamily(device)) return false; - const provider = resolveAppleRunnerRuntime(device, options); - return provider.hasLiveSession ? provider.hasLiveSession(device) : true; + return resolveAppleRunnerRuntime(device, options).hasLiveSession(device); } const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, { diff --git a/packages/platform-apple/src/runner/runner-provider.ts b/packages/platform-apple/src/runner/runner-provider.ts index 5a295ef7a0..b54c52efd4 100644 --- a/packages/platform-apple/src/runner/runner-provider.ts +++ b/packages/platform-apple/src/runner/runner-provider.ts @@ -75,10 +75,10 @@ export type AppleRunnerProvider = { */ prewarm?: AppleRunnerPrewarmExecutor; /** - * Whether a command sent now is answered without waiting for a runner startup. A provider with - * no startup cost (scripted, request-scoped transports) omits this and counts as live. + * Whether a command sent now is answered without waiting for a runner startup. Every provider + * states it: registered is not ready, and a provider with no startup cost says so explicitly. */ - hasLiveSession?: (device: DeviceInfo) => boolean; + hasLiveSession: (device: DeviceInfo) => boolean; }; export type AppleRunnerProviderScopeOptions = { @@ -96,7 +96,7 @@ const appleRunnerProviderScope = new AsyncLocalStorage export function createLocalAppleRunnerProvider( runCommand: AppleRunnerCommandExecutor, - lifecycle: Pick = {}, + lifecycle: Pick, ): AppleRunnerProvider { return { runCommand, ...lifecycle }; } @@ -144,7 +144,8 @@ function normalizeAppleRunnerProvider( provider: AppleRunnerProvider | AppleRunnerCommandExecutor, ): AppleRunnerProvider { if (typeof provider === 'function') { - return { runCommand: provider }; + // A bare executor has no session to start: every command it accepts is answered directly. + return { runCommand: provider, hasLiveSession: () => true }; } return provider; } diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index e1e5359a6d..7f49300440 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -137,16 +137,20 @@ async function admitAppleNativeFind( if (isMacOs(request.device) && surface !== undefined && surface !== 'app') return undefined; const signal = input.signal ? AbortSignal.any([request.signal, input.signal]) : request.signal; signal.throwIfAborted(); - if (await simulatorRunnerNotLive(host, request.device, input.execution)) return undefined; + if (!(await runnerCanAnswerNow(host, request.device, input.execution))) return undefined; return { appBundleId, signal }; } -/** A local iOS Simulator whose runner session is not alive; see the find-runtime doc above. */ -async function simulatorRunnerNotLive( +/** + * Whether the runner can answer a native find without a startup wait. Off a local Simulator the + * runner is the only reader, so it always answers; on one, only a ready session does (see the + * find-runtime doc above). + */ +async function runnerCanAnswerNow( host: Pick, device: DeviceInfo, execution: Readonly<{ requestId?: string }> | undefined, ): Promise { - if (!isIosFamily(device) || device.kind !== 'simulator') return false; - return !(await host.appleApplications.hasLiveRunnerSession(device, execution ?? {})); + if (!isIosFamily(device) || device.kind !== 'simulator') return true; + return await host.appleApplications.hasLiveRunnerSession(device, execution ?? {}); } diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 7aced3c108..651f403eb9 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -483,6 +483,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR host, device: request.device, signal: request.scope.signal, + observation: snapshotRoute, }), facts.operations, ), diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts new file mode 100644 index 0000000000..702769fd65 --- /dev/null +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -0,0 +1,151 @@ +import { expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createLaunchObservationProbe } from './snapshot-observability.ts'; +import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts'; + +const simulator = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + target: 'mobile', + booted: true, +} as const satisfies DeviceInfo; + +const target = { + udid: simulator.id, + runtime: 'iOS 26.0', + pid: 42, + generation: '42:launch-a', + targetId: `${simulator.id}:com.example.app`, + processStartTime: 'Sat Sep 6 09:00:00 2026', +} as const; + +const failed = ( + code: string, + kind: SnapshotSourceFailure['kind'] = 'unsupported', +): SnapshotSourceOutcome => ({ + stage: 'failed', + failure: { kind, code }, +}); +const acquired = (): SnapshotSourceOutcome => ({ + stage: 'acquired', + acquisition: { + producer: 'simulator-ax-bridge', + intent: 'full', + nodes: [], + residue: [], + } as unknown as Extract['acquisition'], +}); + +function probe( + outcomes: readonly SnapshotSourceOutcome[], + clock: { now(): number; sleep(ms: number): Promise }, +) { + let index = 0; + const acquire = vi.fn(async () => outcomes[Math.min(index++, outcomes.length - 1)]!); + const sleep = vi.fn(clock.sleep); + const observe = createLaunchObservationProbe({ + source: { acquire, close: async () => {} }, + resolveTarget: async () => target, + clock: { now: clock.now, sleep }, + }); + return { observe, acquire, sleep }; +} + +test('a launched app is observable as soon as the bridge publishes it', async () => { + const { observe, acquire, sleep } = probe([acquired()], { now: () => 0, sleep: async () => {} }); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + 'observable', + ); + expect(acquire).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); +}); + +test('a missing AX server is re-read inside its window until it registers', async () => { + let now = 0; + const { observe, acquire } = probe( + [ + failed('application-server-unavailable', 'transport-failure'), + failed('application-server-unavailable', 'transport-failure'), + acquired(), + ], + { + now: () => now, + sleep: async (ms) => { + now += ms; + }, + }, + ); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + 'observable', + ); + expect(acquire).toHaveBeenCalledTimes(3); +}); + +test('an ownership miss after an AX-server miss shrinks the deadline to the ownership window', async () => { + // AX-server window (5 s) opens at t=0; the ownership miss at t=2 s must end the wait by t=3 s, + // not at t=5 s, so a launch-time system dialog reaches the typed fallback quickly. + let now = 0; + let sleeps = 0; + const { observe, acquire } = probe( + [ + failed('application-server-unavailable', 'transport-failure'), + failed('foreground-owner-unverified'), + ], + { + now: () => now, + sleep: async (ms) => { + // The first poll lands 2 s later (a slow host); every later poll takes what it asked for. + sleeps += 1; + now += sleeps === 1 ? 2_000 : ms; + }, + }, + ); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + 'unobservable', + ); + expect(now).toBeGreaterThanOrEqual(3_000); + expect(now).toBeLessThanOrEqual(3_150); + expect(acquire.mock.calls.length).toBeGreaterThan(2); +}); + +test('the last poll is capped to the remaining window', async () => { + let now = 0; + const sleeps: number[] = []; + const { observe } = probe([failed('foreground-owner-unverified')], { + now: () => now, + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + }); + await observe.awaitObservable(simulator, 'com.example.app', signal()); + expect(Math.max(...sleeps)).toBeLessThanOrEqual(150); + expect(sleeps.reduce((sum, ms) => sum + ms, 0)).toBeLessThanOrEqual(1_000); +}); + +test('a failure outside the launch transition ends the wait at once', async () => { + const { observe, acquire, sleep } = probe( + [failed('bridge-disconnected', 'transport-failure'), acquired()], + { now: () => 0, sleep: async () => {} }, + ); + await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe( + 'unobservable', + ); + expect(acquire).toHaveBeenCalledOnce(); + expect(sleep).not.toHaveBeenCalled(); +}); + +test('a device without a bridge is not eligible', async () => { + const { observe, acquire } = probe([acquired()], { now: () => 0, sleep: async () => {} }); + await expect( + observe.awaitObservable({ ...simulator, kind: 'device' }, 'com.example.app', signal()), + ).resolves.toBe('not-eligible'); + expect(acquire).not.toHaveBeenCalled(); +}); + +function signal(): AbortSignal { + return new AbortController().signal; +} diff --git a/packages/platform-apple/src/snapshot-observability.ts b/packages/platform-apple/src/snapshot-observability.ts new file mode 100644 index 0000000000..8c24086128 --- /dev/null +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -0,0 +1,72 @@ +import { + createIosSnapshotRequest, + deriveIosCaptureHint, +} from '@agent-device/capture-kit/ios-snapshot-planning'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts'; +import type { SimulatorSnapshotTargetResolver } from './snapshot-target.ts'; + +/** + * What an `open` learned about the app it just launched on a local Simulator: `observable` means + * the host AX bridge published its tree, so the first observation will not pay a launch + * transition; `unobservable` means the bridge reported a state that will not clear within its + * grace (a system dialog, a lost AX server) or the grace ran out; `not-eligible` means the device + * has no bridge to ask. + */ +export type LaunchObservation = 'observable' | 'unobservable' | 'not-eligible'; + +export type LaunchObservationPort = Readonly<{ + awaitObservable( + device: DeviceInfo, + appBundleId: string, + signal: AbortSignal, + ): Promise; +}>; + +/** + * A freshly launched app is not yet the primary foreground owner while SpringBoard animates it + * in, and its accessibility server registers a moment after its process appears. The bridge + * reports those states as typed failures. Each code gets its own window, measured from the first + * failure (the bridge's own cold start may already have consumed the launch) and never extended: + * a stricter code seen later shrinks the deadline, so an AX-server miss followed by an ownership + * miss gets the ownership window, and a system dialog still reaches the caller's typed fallback + * quickly. Every other failure ends the wait at once. + */ +const OBSERVATION_POLL_MS = 150; +const LAUNCH_TRANSITION_WINDOW_MS: ReadonlyMap = new Map([ + ['application-element-missing', 5_000], + ['application-server-unavailable', 5_000], + ['foreground-owner-unverified', 1_000], + ['foreground-owner-changed', 1_000], +]); + +export function createLaunchObservationProbe( + deps: Readonly<{ + source: SimulatorSnapshotSource; + resolveTarget: SimulatorSnapshotTargetResolver; + clock: PlatformRuntimeHost['clock']; + }>, +): LaunchObservationPort { + const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true })); + return Object.freeze({ + awaitObservable: async (device, appBundleId, signal) => { + if (!isIosFamily(device) || device.kind !== 'simulator') return 'not-eligible'; + let deadline: number | undefined; + for (;;) { + const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined); + signal.throwIfAborted(); + if (!target) return 'unobservable'; + const outcome = await deps.source.acquire({ target, hint, signal }); + if (outcome.stage !== 'failed') return 'observable'; + signal.throwIfAborted(); + const windowMs = LAUNCH_TRANSITION_WINDOW_MS.get(outcome.failure.code); + if (windowMs === undefined) return 'unobservable'; + const now = deps.clock.now(); + deadline = Math.min(deadline ?? Number.POSITIVE_INFINITY, now + windowMs); + if (now >= deadline) return 'unobservable'; + await deps.clock.sleep(Math.min(OBSERVATION_POLL_MS, deadline - now), signal); + } + }, + }); +} diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 9ad0426f25..ce1525ceb9 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -238,161 +238,3 @@ function runnerResult() { function signal(): AbortSignal { return new AbortController().signal; } - -const OWNER_UNVERIFIED = { - stage: 'failed', - failure: { kind: 'unsupported', code: 'foreground-owner-unverified' }, -} as const satisfies SnapshotSourceOutcome; - -function lstart(msAgo: number, nowMs: number): string { - return new Date(nowMs - msAgo).toString().replace(/ GMT.*$/, ''); -} - -function launchGraceRoute( - outcomes: readonly SnapshotSourceOutcome[], - processStartTime: string, - clock: { now(): number; sleep(ms: number, signal?: AbortSignal): Promise }, -) { - const acquire = vi.fn( - async () => outcomes[Math.min(acquire.mock.calls.length - 1, outcomes.length - 1)]!, - ); - const presentIosAcquisition = vi.fn(async () => ({ - backend: 'xctest' as const, - producer: 'simulator-ax-bridge' as const, - nodes: [{ index: 0, type: 'Application' }], - })); - const fallback = vi.fn(async () => runnerResult()); - const route = createAppleSnapshotRoute( - { - ...platformRuntimeHostFixture(), - clock, - snapshot: { captureSurface: vi.fn(), presentIosAcquisition }, - }, - { - source: { acquire, close: vi.fn(async () => {}) }, - resolveTarget: vi.fn(async () => ({ ...target, processStartTime })), - }, - ); - return { route, acquire, fallback, presentIosAcquisition }; -} - -test('a foreground-owner miss on a just-launched target is re-read inside the launch grace', async () => { - const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); - const sleep = vi.fn(async () => {}); - const { route, acquire, fallback } = launchGraceRoute( - [OWNER_UNVERIFIED, bridgeAcquisition()], - lstart(800, nowMs), - { now: () => nowMs, sleep }, - ); - - await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ - producer: 'simulator-ax-bridge', - }); - expect(acquire).toHaveBeenCalledTimes(2); - expect(sleep).toHaveBeenCalledOnce(); - expect(fallback).not.toHaveBeenCalled(); -}); - -test('an unregistered AX server on a just-launched target is re-read inside the launch grace', async () => { - const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); - const { route, acquire, fallback } = launchGraceRoute( - [ - { - stage: 'failed', - failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, - }, - bridgeAcquisition(), - ], - lstart(1_200, nowMs), - { now: () => nowMs, sleep: async () => {} }, - ); - - await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ - producer: 'simulator-ax-bridge', - }); - expect(acquire).toHaveBeenCalledTimes(2); - expect(fallback).not.toHaveBeenCalled(); -}); - -test('a bridge transport loss on a just-launched target is not a launch transition', async () => { - const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); - const { route, acquire, fallback } = launchGraceRoute( - [{ stage: 'failed', failure: { kind: 'transport-failure', code: 'bridge-disconnected' } }], - lstart(500, nowMs), - { now: () => nowMs, sleep: async () => {} }, - ); - - await route.capture(ios, input, signal(), fallback); - expect(acquire).toHaveBeenCalledOnce(); - expect(fallback).toHaveBeenCalledOnce(); -}); - -test('a foreground-owner miss on an established target falls back at once', async () => { - const nowMs = Date.parse('2026-09-06T00:00:10.000Z'); - const sleep = vi.fn(async () => {}); - const { route, acquire, fallback } = launchGraceRoute( - [OWNER_UNVERIFIED, bridgeAcquisition()], - lstart(60_000, nowMs), - { now: () => nowMs, sleep }, - ); - - await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ - warnings: [expect.stringContaining('foreground-owner-unverified')], - }); - expect(acquire).toHaveBeenCalledOnce(); - expect(sleep).not.toHaveBeenCalled(); - expect(fallback).toHaveBeenCalledOnce(); -}); - -test('the ownership grace is one second from the first miss, then the typed fallback applies', async () => { - let nowMs = Date.parse('2026-09-06T00:00:10.000Z'); - const startedAt = lstart(4_000, nowMs); - const sleep = vi.fn(async () => { - nowMs += 400; - }); - const { route, acquire, fallback } = launchGraceRoute([OWNER_UNVERIFIED], startedAt, { - now: () => nowMs, - sleep, - }); - - await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ - warnings: [expect.stringContaining('foreground-owner-unverified')], - }); - expect(acquire.mock.calls.length).toBeGreaterThan(1); - expect(acquire.mock.calls.length).toBeLessThanOrEqual(4); - expect(fallback).toHaveBeenCalledOnce(); -}); - -test('the AX-server grace outlives the bridge cold start that consumed the launch', async () => { - // First failure observed 6 s after the process appeared (a cold bridge start), still young. - let nowMs = Date.parse('2026-09-06T00:00:10.000Z'); - const startedAt = lstart(6_000, nowMs); - const sleep = vi.fn(async () => { - nowMs += 500; - }); - const { route, acquire, fallback } = launchGraceRoute( - [ - { - stage: 'failed', - failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, - }, - { - stage: 'failed', - failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, - }, - { - stage: 'failed', - failure: { kind: 'transport-failure', code: 'application-server-unavailable' }, - }, - bridgeAcquisition(), - ], - startedAt, - { now: () => nowMs, sleep }, - ); - - await expect(route.capture(ios, input, signal(), fallback)).resolves.toMatchObject({ - producer: 'simulator-ax-bridge', - }); - expect(acquire).toHaveBeenCalledTimes(4); - expect(fallback).not.toHaveBeenCalled(); -}); diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index e87e9687df..079d085d2d 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -22,8 +22,11 @@ import { createSimulatorSnapshotSource, type SimulatorSnapshotSource, type SnapshotSourceFailure, - type SnapshotSourceOutcome, } from './snapshot-source-facade.ts'; +import { + createLaunchObservationProbe, + type LaunchObservationPort, +} from './snapshot-observability.ts'; import { createSimulatorSnapshotTargetResolver, type SimulatorSnapshotTarget, @@ -32,34 +35,16 @@ import { type SnapshotFallback = (input: CaptureSnapshotInput) => Promise; -/** - * A freshly launched app is not yet the primary foreground owner while SpringBoard animates it in, - * and its accessibility server registers a moment after its process appears. The bridge reports - * those states as typed failures. Falling back on them would start the XCTest runner for a snapshot - * the bridge serves a moment later, so a young target is re-read for a bounded grace before the - * typed fallback applies. The grace is measured from the first failure, because the bridge's own - * cold start may already have consumed the launch, and it is short for ownership misses: a system - * dialog produces the same code and must still reach the fallback quickly. An established target - * gets no grace at all. - */ -const LAUNCH_YOUNG_TARGET_MS = 10_000; -const LAUNCH_GRACE_POLL_MS = 150; -const LAUNCH_GRACE_BY_CODE: ReadonlyMap = new Map([ - ['application-element-missing', 5_000], - ['application-server-unavailable', 5_000], - ['foreground-owner-unverified', 1_000], - ['foreground-owner-changed', 1_000], -]); - -export type AppleSnapshotRoute = Readonly<{ - capture( - device: DeviceInfo, - input: CaptureSnapshotInput, - signal: AbortSignal, - fallback: SnapshotFallback, - ): Promise; - shutdown(): Promise; -}>; +export type AppleSnapshotRoute = LaunchObservationPort & + Readonly<{ + capture( + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, + fallback: SnapshotFallback, + ): Promise; + shutdown(): Promise; + }>; export function createAppleSnapshotRoute( host: PlatformRuntimeHost, @@ -72,8 +57,10 @@ export function createAppleSnapshotRoute( const resolveTarget = options.resolveTarget ?? createSimulatorSnapshotTargetResolver(); const disabledGenerations = new Set(); const latestGeneration = new Map(); + const observation = createLaunchObservationProbe({ source, resolveTarget, clock: host.clock }); return Object.freeze({ + awaitObservable: observation.awaitObservable, shutdown: async () => await source.close(), capture: async (device, input, signal, fallback) => { if (!isEligible(device, input)) return await fallback(input); @@ -99,7 +86,7 @@ export function createAppleSnapshotRoute( } const request = requestFor(input); - const outcome = await acquireWithinLaunchGrace(source, host.clock, device, target, { + const outcome = await source.acquire({ target, hint: deriveIosCaptureHint(request), signal, @@ -255,44 +242,6 @@ async function resolveFailureFallbackIdentity( } } -/** One acquisition, re-read while the launch grace for its typed failure still holds. */ -async function acquireWithinLaunchGrace( - source: SimulatorSnapshotSource, - clock: PlatformRuntimeHost['clock'], - device: DeviceInfo, - target: SimulatorSnapshotTarget, - request: Parameters[0], -): Promise { - let outcome = await source.acquire(request); - if (outcome.stage !== 'failed') return outcome; - const deadline = clock.now() + launchGraceFor(outcome.failure, target, clock.now()); - while ( - outcome.stage === 'failed' && - LAUNCH_GRACE_BY_CODE.has(outcome.failure.code) && - clock.now() < deadline - ) { - emitRouteDiagnostic('launch-grace-retry', device, target.generation, undefined, { - code: outcome.failure.code, - }); - await clock.sleep(LAUNCH_GRACE_POLL_MS, request.signal); - outcome = await source.acquire(request); - } - return outcome; -} - -function launchGraceFor( - failure: SnapshotSourceFailure, - target: SimulatorSnapshotTarget, - nowMs: number, -): number { - const graceMs = LAUNCH_GRACE_BY_CODE.get(failure.code); - if (graceMs === undefined) return 0; - // `ps -o lstart=` text; an unparseable start time counts as established, never as young. - const startedAtMs = Date.parse(target.processStartTime); - if (Number.isNaN(startedAtMs)) return 0; - return Math.max(0, nowMs - startedAtMs) < LAUNCH_YOUNG_TARGET_MS ? graceMs : 0; -} - function unknownGenerationResidue(): IosAcquisitionResidue { return { kind: 'unknown-generation', captureId: randomUUID() }; } diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index 6fa9b42115..5fef270d41 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -249,7 +249,7 @@ test('generic Apple runner provider cannot fall back to local recording authorit req: request('record'), existingSession: makeMacOsSession('macos-session'), providers: { - appleRunnerProvider: () => ({ runCommand: async () => ({}) }), + appleRunnerProvider: () => ({ runCommand: async () => ({}), hasLiveSession: () => true }), }, }, async () => { @@ -289,7 +289,7 @@ test('focused Apple runner recording authority remains exact across recreated re }, }); const providers = { - appleRunnerProvider: () => ({ runCommand: async () => ({}) }), + appleRunnerProvider: () => ({ runCommand: async () => ({}), hasLiveSession: () => true }), appleRunnerScreenRecordingTransport: () => transport, }; const runnerSessionId = await withRequestPlatformProviderScope( diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index d7d62d168a..986d43ae66 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -235,6 +235,7 @@ function richNodes() { function permissiveRunner(): AppleRunnerProvider { return { + hasLiveSession: () => true, runCommand: async (_device, command) => { switch (command.command) { case 'uptime': diff --git a/test/integration/provider-scenarios/macos-desktop.test.ts b/test/integration/provider-scenarios/macos-desktop.test.ts index 0a61420dbe..f17bad59de 100644 --- a/test/integration/provider-scenarios/macos-desktop.test.ts +++ b/test/integration/provider-scenarios/macos-desktop.test.ts @@ -16,6 +16,7 @@ import type { test('Provider-backed integration prepare uses the Apple runner lifecycle provider', async () => { const lifecycleCalls: string[] = []; const appleRunnerProvider: AppleRunnerProvider = { + hasLiveSession: () => true, runCommand: async () => { throw new Error('prepare should not be reduced to a raw runner command'); }, diff --git a/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts b/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts index d2a158ae93..12c3a4babc 100644 --- a/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts +++ b/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts @@ -212,6 +212,7 @@ function createProviderRuntime( options: { requestScope: boolean }, ): ProviderDeviceRuntime { const transport: AppleRunnerProvider = { + hasLiveSession: () => true, runCommand: async (_device, command, options) => { calls.runner.push({ command, options }); return runnerResultFor(command); diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 9ee9810bcb..3a049e05eb 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -29,6 +29,8 @@ export function createAppleRunnerProviderFromTranscript( deviceId: device.id, platform: device.platform, }) as Record, + // A scripted runner has no startup: the transcript answers every command directly. + hasLiveSession: () => true, }; } From c0854018ccedc856c5ed4f149bc190c0bcfd8625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:25:37 +0200 Subject: [PATCH 11/21] docs(context): keep the runner-demand vocabulary within the guidance budget The enumeration and the no-public-flag rule live on the contract type that owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose words that carried no meaning. --- CONTEXT.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index fb4fb86a23..05e0de4382 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,30 +110,25 @@ A device-scoped pause on agent mutations during human operation. ### Commands and routing **Command surface**: -The catalog of public command identity, interface exposure, adapter policy, and metadata across -entrypoints. +The catalog of public command identity, exposure, adapter policy, and metadata across entrypoints. **Runtime use**: -A command's platform-neutral declaration of required operations and optional preferred fast paths. +A command's platform-neutral declaration of required operations and preferred fast paths. **Inventory use**: -An inventory command's platform-neutral declaration for composing device sources without binding one. +An inventory command's platform-neutral declaration for composing device sources unbound. **Daemon command registry**: The daemon-side truth for route ownership and request-policy traits. **Runner command traits**: -Per-command classifications controlling Apple runner lifecycle and recovery behavior -independently of the public command surface. +Per-command classes steering Apple runner lifecycle and recovery, independent of the public surface. **Runner demand**: -The Apple owner's decision, per local-Simulator open, of how much the XCTest runner is known to be -needed by the steps still ahead in the same batch: `none` starts no runner, `possible` (unknown -plan) prewarms without ever awaiting readiness, `required` prepares readiness for the first -runner-dependent step. Derived from the commands' declared runtime operations, never a public flag. +What a local-Simulator open prepares of the XCTest runner for the remaining batch steps. **Daemon RPC protocol version**: -The integer used to detect breaking compatibility across the remote daemon boundary. +The integer that detects breaking compatibility across the remote daemon boundary. **Version-skew invariant**: Local client and daemon versions must match; only remote daemons, separately versioned helpers, From 6cfc93e40be8e0dd30ab513bec4ef647d2d22ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:29:32 +0200 Subject: [PATCH 12/21] refactor(contracts): name the runtime operation vocabulary below the operations union The lifecycle execution carries the operations a plan requires, but typing that list with the operations union closed a 36-file type cycle: the operations types depend on the lifecycle types. The vocabulary now lives as a const list below both, proven equal to the union by a type test, so the plan is typed end to end, the Apple host table indexes it without casts, and the daemon narrows descriptor names through a guard instead of a cast. --- packages/contracts/package.json | 4 + .../src/application-lifecycle-runtime.ts | 6 +- .../src/platform-runtime-operations.test.ts | 29 +++++- .../contracts/src/runtime-operation-names.ts | 96 +++++++++++++++++++ packages/platform-apple/src/runner-demand.ts | 9 +- .../layering/contracts-exports.snapshot.json | 1 + .../command-descriptor/planned-operations.ts | 14 ++- 7 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 packages/contracts/src/runtime-operation-names.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2e80568bea..7ac2901583 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -359,6 +359,10 @@ "types": "./src/platform-runtime-unavailable.ts", "default": "./src/platform-runtime-unavailable.ts" }, + "./runtime-operation-names": { + "types": "./src/runtime-operation-names.ts", + "default": "./src/runtime-operation-names.ts" + }, "./progress": { "types": "./src/facades/progress.ts", "default": "./src/facades/progress.ts" diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index dd74ca678a..408c63d420 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -1,5 +1,5 @@ -import type { RuntimeOperationFact, RuntimeOperationKey } from './platform-runtime.ts'; -import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; +import type { RuntimeOperationName } from './runtime-operation-names.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; @@ -46,7 +46,7 @@ export type ApplicationLifecycleExecution = Readonly<{ * declared runtime uses. Never a public flag and never on the wire. Absent when the future of the * session is unknown (a standalone command). */ - plannedOperations?: readonly RuntimeOperationKey[]; + plannedOperations?: readonly RuntimeOperationName[]; }>; /** Semantic target resolution used before an application open. */ diff --git a/packages/contracts/src/platform-runtime-operations.test.ts b/packages/contracts/src/platform-runtime-operations.test.ts index fd9f3fb9a7..17d24c4629 100644 --- a/packages/contracts/src/platform-runtime-operations.test.ts +++ b/packages/contracts/src/platform-runtime-operations.test.ts @@ -1,13 +1,23 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; -import { localRuntimeOwner, providerRuntimeOwner } from './platform-runtime.ts'; import { + localRuntimeOwner, + providerRuntimeOwner, + type RuntimeOperationKey, +} from './platform-runtime.ts'; +import { + RUNTIME_OPERATION_NAMES, + isRuntimeOperationName, + type RuntimeOperationName, +} from './runtime-operation-names.ts'; +import { + type PlatformRuntimeOperations, + type PlatformRuntimeProviderModule, bootTargetHeadlessUse, bootTargetUse, captureSnapshotUse, resolveDeviceReadinessRuntimePlan, resolveSnapshotRuntimePlan, - type PlatformRuntimeProviderModule, } from './platform-runtime-operations.ts'; function compileTimeProviderModuleProof(): void { @@ -99,3 +109,18 @@ test.each([ }); }, ); + +// The value-level vocabulary must name exactly the operations union: a name missing from the list +// or an extra name both collapse these assignments to a compile error. +type OperationKey = RuntimeOperationKey; +type MissingFromList = Exclude; +type ExtraInList = Exclude; +const noOperationIsMissingFromTheList: [MissingFromList] extends [never] ? true : never = true; +const noListedNameIsUnknown: [ExtraInList] extends [never] ? true : never = true; + +test('the runtime operation vocabulary is the operations union, with no duplicates', () => { + assert.equal(noOperationIsMissingFromTheList && noListedNameIsUnknown, true); + assert.equal(new Set(RUNTIME_OPERATION_NAMES).size, RUNTIME_OPERATION_NAMES.length); + assert.equal(isRuntimeOperationName('captureSnapshot'), true); + assert.equal(isRuntimeOperationName('notAnOperation'), false); +}); diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts new file mode 100644 index 0000000000..dc35310d63 --- /dev/null +++ b/packages/contracts/src/runtime-operation-names.ts @@ -0,0 +1,96 @@ +/** + * The runtime operation vocabulary as a value, for contracts that name operations without + * importing the operations union that sits above them (the lifecycle execution carries the + * operations a plan requires; the operations types depend on the lifecycle types). The + * `platform-runtime-operations` test proves this list and `RuntimeOperationKey` + * name exactly the same members, so adding an operation refuses to pass until it is listed here. + */ +export const RUNTIME_OPERATION_NAMES = [ + 'acceptAlert', + 'appLogCleanup', + 'appLogDoctor', + 'appLogInspect', + 'appLogReattach', + 'appLogStart', + 'appState', + 'appSwitcher', + 'applyRuntimeHints', + 'audioProbeCleanup', + 'audioProbeQuery', + 'audioProbeReattach', + 'audioProbeStart', + 'awaitAlert', + 'back', + 'bootTarget', + 'bootTargetHeadless', + 'captureScreenshot', + 'captureSnapshot', + 'captureSnapshotWithCustomActions', + 'captureSnapshotWithoutActiveApp', + 'clearRuntimeHints', + 'closeApplication', + 'configureProviderPortReverse', + 'deployApp', + 'deployMaterializedApp', + 'dismissAlert', + 'ensureReady', + 'fillPoint', + 'fillRef', + 'finalizeApplicationClose', + 'findSelector', + 'findText', + 'focusPoint', + 'gestureViewport', + 'home', + 'hoverPoint', + 'hoverRef', + 'keyboardDismiss', + 'keyboardEnter', + 'keyboardStatus', + 'listApps', + 'longPressPoint', + 'materializeAppSource', + 'networkDump', + 'openApplication', + 'perfFrames', + 'perfMemorySample', + 'perfMemorySnapshot', + 'perfNativeCaptureCleanup', + 'perfNativeCaptureReattach', + 'perfNativeCaptureStart', + 'perfProfileReport', + 'performDirectionalFlingPlan', + 'performGesturePlan', + 'performMultiTouchGesturePlan', + 'performTargetAuthoredDrag', + 'prepareAppleRunner', + 'prepareApplicationOpen', + 'readAlert', + 'readClipboard', + 'readTextAtPoint', + 'resolveOpenTarget', + 'screenRecordingCleanup', + 'screenRecordingReattach', + 'screenRecordingStart', + 'scrollDirection', + 'sendPushNotification', + 'setOrientation', + 'setSetting', + 'setViewport', + 'shutdownTarget', + 'tapElementSelector', + 'tapPoint', + 'tapRef', + 'triggerAppEvent', + 'tvRemote', + 'typeText', + 'writeClipboard', +] as const; + +export type RuntimeOperationName = (typeof RUNTIME_OPERATION_NAMES)[number]; + +const runtimeOperationNames: ReadonlySet = new Set(RUNTIME_OPERATION_NAMES); + +export function isRuntimeOperationName(value: string): value is RuntimeOperationName { + return runtimeOperationNames.has(value); +} diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index d9b40cbb72..c50151a6b5 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -1,6 +1,5 @@ import type { OpenApplicationRunnerDemand } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { RuntimeOperationKey } from '@agent-device/contracts/platform-runtime'; -import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeOperationName } from '@agent-device/contracts/runtime-operation-names'; /** * Which host the Apple runtime executes each declared runtime operation through on a local iOS @@ -9,12 +8,12 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform * * Bridge-eligible snapshots keep their typed XCTest fallback, but a fallback is a recovery, not a * plan requirement, so they classify as `simulator`. The record is complete over the runtime - * operation union by construction: a new operation refuses to compile until it is classified. + * operation vocabulary by construction: a new operation refuses to compile until it is classified. */ type AppleSimulatorOperationHost = 'runner' | 'simulator'; const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< - Record, AppleSimulatorOperationHost> + Record > = Object.freeze({ // Application lifecycle: simctl launch/terminate and host readiness. resolveOpenTarget: 'simulator', @@ -107,7 +106,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< * any runner-served operation makes readiness worth preparing now. */ export function resolveAppleSimulatorRunnerDemand( - operations: readonly RuntimeOperationKey[] | undefined, + operations: readonly RuntimeOperationName[] | undefined, ): OpenApplicationRunnerDemand { if (operations === undefined) return 'possible'; return operations.some((operation) => APPLE_SIMULATOR_OPERATION_HOSTS[operation] === 'runner') diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 8a04d0ce76..3f0fc0a3af 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -93,6 +93,7 @@ "@agent-device/contracts/remote", "@agent-device/contracts/replay", "@agent-device/contracts/runner-lease-context", + "@agent-device/contracts/runtime-operation-names", "@agent-device/contracts/screen-recording-runtime", "@agent-device/contracts/screen-recording-runtime-host", "@agent-device/contracts/screen-recording-runtime-plan", diff --git a/src/core/command-descriptor/planned-operations.ts b/src/core/command-descriptor/planned-operations.ts index 9df94a16e8..ead5cb3f59 100644 --- a/src/core/command-descriptor/planned-operations.ts +++ b/src/core/command-descriptor/planned-operations.ts @@ -1,6 +1,8 @@ import type { RuntimeUseStep } from '@agent-device/contracts/command-platform-execution'; -import type { RuntimeOperationKey } from '@agent-device/contracts/platform-runtime'; -import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { + isRuntimeOperationName, + type RuntimeOperationName, +} from '@agent-device/contracts/runtime-operation-names'; import { commandDescriptors } from './registry.ts'; import type { CommandDescriptor } from './types.ts'; @@ -11,7 +13,7 @@ const descriptorsByName = new Map( /** One step of a plan as the batch runner holds it: the command plus what its handler will read. */ export type PlannedStep = RuntimeUseStep & Readonly<{ command: string }>; -export type PlannedRuntimeOperation = RuntimeOperationKey; +export type PlannedRuntimeOperation = RuntimeOperationName; /** * The runtime operations a sequence of steps must execute, read from each command's declared @@ -34,8 +36,10 @@ export function resolvePlannedRuntimeOperations( const uses = 'uses' in execution ? (execution.selectUses?.(step) ?? execution.uses) : [execution.use]; for (const use of uses) { - // Every declared use is built with `defineUse`, which admits only operation keys. - for (const operation of use.required as readonly PlannedRuntimeOperation[]) { + for (const operation of use.required) { + // `defineUse` admits only operation keys, so an unknown name means the vocabulary list + // and the operations union drifted; treat the plan as unproven rather than guess. + if (!isRuntimeOperationName(operation)) return undefined; operations.add(operation); } } From 646b2a58eaf59b27ca6327e07f6a94055fef6f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:32:39 +0200 Subject: [PATCH 13/21] fix(apple): reach runner liveness through the memoized operations loader Every Apple tool port loads the runner operations through the one memoized loader (#2314): a port that opens its own dynamic import can resolve the unmocked module while a test's mock factory is still loading and let a real local runner escape. The liveness port now uses the loader like its siblings; the facade members consumed only through the loader are declared to fallow, and the plan resolver reads one step per helper to stay under the complexity threshold. --- .fallowrc.json | 9 ++++++ .../command-descriptor/planned-operations.ts | 30 ++++++++++--------- ...latform-runtime-apple-application-tools.ts | 3 +- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index 3301417867..acc7129e14 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -91,6 +91,15 @@ "file": "packages/platform-apple/src/app-resolution-facade.ts", "exports": ["buildAppNotInstalledError"] }, + { + "comment": "Runner operations reach the daemon only through the memoized loader in src/platform-runtime-apple-application-tools.ts (one dynamic import per specifier, #2314). Fallow cannot connect a member read off that loader's promise to these re-exports; keep the list to the facade members no static import consumes.", + "file": "packages/platform-apple/src/runner-operations-facade.ts", + "exports": [ + "detachIosSimulatorRunnerSessionsForShutdown", + "hasLiveIosRunnerSession", + "stopAllIosRunnerSessions" + ] + }, { "comment": "Apple install mechanics are reached through the named install-artifact façade. Fallow cannot connect workspace package exports to these source exports; keep this list limited to the actual facade re-exports.", "file": "packages/platform-apple/src/core/install-artifact.ts", diff --git a/src/core/command-descriptor/planned-operations.ts b/src/core/command-descriptor/planned-operations.ts index ead5cb3f59..f75f82fafa 100644 --- a/src/core/command-descriptor/planned-operations.ts +++ b/src/core/command-descriptor/planned-operations.ts @@ -29,20 +29,22 @@ export function resolvePlannedRuntimeOperations( ): readonly PlannedRuntimeOperation[] | undefined { const operations = new Set(); for (const step of steps) { - const descriptor = descriptorsByName.get(step.command); - if (!descriptor) return undefined; - const execution = descriptor.platformExecution; - if (execution.kind !== 'device-runtime') continue; - const uses = - 'uses' in execution ? (execution.selectUses?.(step) ?? execution.uses) : [execution.use]; - for (const use of uses) { - for (const operation of use.required) { - // `defineUse` admits only operation keys, so an unknown name means the vocabulary list - // and the operations union drifted; treat the plan as unproven rather than guess. - if (!isRuntimeOperationName(operation)) return undefined; - operations.add(operation); - } - } + const required = requiredOperationsOf(step); + if (required === undefined) return undefined; + for (const operation of required) operations.add(operation); } return Object.freeze([...operations].sort()); } + +/** One step's required operations, or `undefined` when the step cannot be planned honestly. */ +function requiredOperationsOf(step: PlannedStep): readonly PlannedRuntimeOperation[] | undefined { + const execution = descriptorsByName.get(step.command)?.platformExecution; + if (execution === undefined) return undefined; + if (execution.kind !== 'device-runtime') return []; + const uses = + 'uses' in execution ? (execution.selectUses?.(step) ?? execution.uses) : [execution.use]; + const required = uses.flatMap((use) => use.required); + // `defineUse` admits only operation keys, so an unknown name means the vocabulary list and the + // operations union drifted; treat the plan as unproven rather than guess. + return required.every(isRuntimeOperationName) ? required : undefined; +} diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 9c49db9e94..0cf0d87b79 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -77,8 +77,7 @@ export function createAppleApplicationTools(): AppleApplicationTools { await stopIosRunnerSession(deviceId); }, hasLiveRunnerSession: async (device, execution) => { - const { hasLiveIosRunnerSession } = - await import('@agent-device/platform-apple/runner/operations'); + const { hasLiveIosRunnerSession } = await loadRunnerOperations(); return hasLiveIosRunnerSession(device, { requestId: execution.requestId }); }, scheduleRunnerIdleStop: (deviceId) => { From d654cc37765b229364203d7b49f32e265338f0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:37:56 +0200 Subject: [PATCH 14/21] fix(ios): keep bridge-only behavior to iOS Simulators The launch observation, the runner-free find admission, and the relaunch policy apply only where the host AX bridge exists: iOS Simulators. A tvOS Simulator keeps its awaited prewarm and asks for no observation, which the tvOS provider scenario now pins. --- packages/platform-apple/src/lifecycle.test.ts | 26 +++++++++++++++++++ packages/platform-apple/src/open-policy.ts | 11 +++++--- .../platform-apple/src/runtime-snapshot.ts | 11 ++++---- .../src/snapshot-observability.test.ts | 11 +++++--- .../src/snapshot-observability.ts | 9 +++++-- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index 0a315047c9..becfbe612a 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -483,3 +483,29 @@ test('a Simulator whose bridge cannot answer keeps the fixed settle', async () = expect(outcome.timing.postOpenObservation).toBe('unobservable'); expect(events).toEqual(['open', 'sleep']); }); + +test('a tvOS Simulator relaunch keeps the awaited prewarm and asks for no observation', async () => { + const events: string[] = []; + const { host, prewarmRunnerSession, notifyRunnerAppRelaunched } = simulatorHost({ events }); + const awaitObservable = vi.fn(async () => 'observable' as const); + const tvos = { ...simulator, appleOs: 'tvos', target: 'tv' } as const satisfies DeviceInfo; + const lifecycle = bindAppleApplicationLifecycle({ + host, + device: tvos, + signal: new AbortController().signal, + observation: { awaitObservable }, + }); + + const outcome = await lifecycle.openApplication({ + ...openInput(), + relaunch: true, + execution: { plannedOperations: ['captureSnapshot'] }, + }); + + expect(outcome.timing.runnerDemand).toBeUndefined(); + expect(outcome.timing.runnerPrewarmWaited).toBe(true); + expect(outcome.timing.postOpenObservation).toBeUndefined(); + expect(awaitObservable).not.toHaveBeenCalled(); + expect(prewarmRunnerSession).toHaveBeenCalledOnce(); + expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index 571e6756db..d6b6104d53 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -6,7 +6,7 @@ import type { import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { resolveAppleSimulatorRunnerDemand } from './runner-demand.ts'; -import type { LaunchObservationPort } from './snapshot-observability.ts'; +import { hasSimulatorBridge, type LaunchObservationPort } from './snapshot-observability.ts'; const POST_OPEN_SETTLE_MS = 300; @@ -31,7 +31,10 @@ export function resolveRunnerPrewarmPolicy( input: OpenApplicationInput, localIosSimulator: boolean, ): RunnerPrewarmPolicy { - const runnerDemand = localIosSimulator + // Only a Simulator with the host AX bridge has a runner-free observation path, so only it + // consults the plan and skips the relaunch wait; every other Apple target keeps its lifecycle. + const bridge = localIosSimulator && hasSimulatorBridge(device); + const runnerDemand = bridge ? resolveAppleSimulatorRunnerDemand(input.execution.plannedOperations) : undefined; const shouldPrewarmRunner = @@ -43,7 +46,7 @@ export function resolveRunnerPrewarmPolicy( return { ...(runnerDemand ? { runnerDemand } : {}), shouldPrewarmRunner, - awaitPrewarmAfterOpen: input.relaunch && !localIosSimulator, + awaitPrewarmAfterOpen: input.relaunch && !bridge, }; } @@ -62,7 +65,7 @@ export async function settleAppleOpen( timing: MutableOpenTiming, ): Promise { const startedAtMs = Date.now(); - if (localIosSimulator && observation && input.appBundleId) { + if (localIosSimulator && hasSimulatorBridge(binding.device) && observation && input.appBundleId) { timing.postOpenObservation = await observation.awaitObservable( binding.device, input.appBundleId, diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index 7f49300440..e44a026b70 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -13,7 +13,8 @@ import type { PlatformRuntimeHost, PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; -import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { hasSimulatorBridge } from './snapshot-observability.ts'; import type { AppleSnapshotRoute } from './snapshot-route.ts'; /** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */ @@ -142,15 +143,15 @@ async function admitAppleNativeFind( } /** - * Whether the runner can answer a native find without a startup wait. Off a local Simulator the - * runner is the only reader, so it always answers; on one, only a ready session does (see the - * find-runtime doc above). + * Whether the runner can answer a native find without a startup wait. Without the Simulator + * bridge the runner is the only reader, so it always answers; with it, only a ready session does + * (see the find-runtime doc above). */ async function runnerCanAnswerNow( host: Pick, device: DeviceInfo, execution: Readonly<{ requestId?: string }> | undefined, ): Promise { - if (!isIosFamily(device) || device.kind !== 'simulator') return true; + if (!hasSimulatorBridge(device)) return true; return await host.appleApplications.hasLiveRunnerSession(device, execution ?? {}); } diff --git a/packages/platform-apple/src/snapshot-observability.test.ts b/packages/platform-apple/src/snapshot-observability.test.ts index 702769fd65..9854396e7a 100644 --- a/packages/platform-apple/src/snapshot-observability.test.ts +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -138,11 +138,14 @@ test('a failure outside the launch transition ends the wait at once', async () = expect(sleep).not.toHaveBeenCalled(); }); -test('a device without a bridge is not eligible', async () => { +test.each([ + ['a physical iOS device', { ...simulator, kind: 'device' as const }], + ['a tvOS Simulator', { ...simulator, appleOs: 'tvos' as const, target: 'tv' as const }], +])('%s has no bridge and is not eligible', async (_name, device) => { const { observe, acquire } = probe([acquired()], { now: () => 0, sleep: async () => {} }); - await expect( - observe.awaitObservable({ ...simulator, kind: 'device' }, 'com.example.app', signal()), - ).resolves.toBe('not-eligible'); + await expect(observe.awaitObservable(device, 'com.example.app', signal())).resolves.toBe( + 'not-eligible', + ); expect(acquire).not.toHaveBeenCalled(); }); diff --git a/packages/platform-apple/src/snapshot-observability.ts b/packages/platform-apple/src/snapshot-observability.ts index 8c24086128..c782a9fca7 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -3,7 +3,7 @@ import { deriveIosCaptureHint, } from '@agent-device/capture-kit/ios-snapshot-planning'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts'; import type { SimulatorSnapshotTargetResolver } from './snapshot-target.ts'; @@ -41,6 +41,11 @@ const LAUNCH_TRANSITION_WINDOW_MS: ReadonlyMap = new Map([ ['foreground-owner-changed', 1_000], ]); +/** Only iOS Simulators carry the host AX bridge; other Apple simulators observe through XCTest. */ +export function hasSimulatorBridge(device: DeviceInfo): boolean { + return device.platform === 'apple' && device.appleOs === 'ios' && device.kind === 'simulator'; +} + export function createLaunchObservationProbe( deps: Readonly<{ source: SimulatorSnapshotSource; @@ -51,7 +56,7 @@ export function createLaunchObservationProbe( const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true })); return Object.freeze({ awaitObservable: async (device, appBundleId, signal) => { - if (!isIosFamily(device) || device.kind !== 'simulator') return 'not-eligible'; + if (!hasSimulatorBridge(device)) return 'not-eligible'; let deadline: number | undefined; for (;;) { const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined); From 60b5e356d97257489c516498e1652d6e1b4f6181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:43:40 +0200 Subject: [PATCH 15/21] bench(ios): add a first-interaction cell to the snapshot convergence harness An open that defers runner readiness moves its cost to the first runner-dependent command. The cell starts each sample like cold, opens the fixture untimed, then times the first press that follows (the deep-link confirmation when the launch URL raises it, otherwise the screen anchor). --- scripts/ios-snapshot-benchmark/README.md | 5 ++++- .../ios-snapshot-benchmark/cell-admission.ts | 6 +++-- scripts/ios-snapshot-benchmark/command.ts | 22 +++++++++++++++++++ .../definitions.test.ts | 2 ++ scripts/ios-snapshot-benchmark/definitions.ts | 2 +- .../ios-snapshot-benchmark/local-runner.ts | 8 ++++--- .../raw-result.schema.v1.json | 14 ++++++++---- scripts/ios-snapshot-benchmark/types.ts | 4 ++-- 8 files changed, 50 insertions(+), 13 deletions(-) diff --git a/scripts/ios-snapshot-benchmark/README.md b/scripts/ios-snapshot-benchmark/README.md index 88f718d6ee..08b36ce99e 100644 --- a/scripts/ios-snapshot-benchmark/README.md +++ b/scripts/ios-snapshot-benchmark/README.md @@ -17,7 +17,7 @@ The app build must succeed on the host. If signing, Xcode, XCTest, simulator, ru ## Local state matrix -Replace `SIMULATOR_UDID` with the dedicated simulator UDID. The default screen set is quiet, list, nested-scroll, alert, system-surface, and xctest-stress. Cold cells require at least 10 samples; warm and relaunch cells require at least 20. +Replace `SIMULATOR_UDID` with the dedicated simulator UDID. The default screen set is quiet, list, nested-scroll, alert, system-surface, and xctest-stress. Cold and first-interaction cells require at least 10 samples; warm and relaunch cells require at least 20. ```sh pnpm bench:ios-snapshot -- \ @@ -35,6 +35,9 @@ The cells mean: - `cold`: simulator booted, daemon stopped, and app terminated before each sample. - `warm`: app, daemon, runner, and target are prepared once; each sample is a fresh CLI snapshot. - `relaunch`: the same prepared tooling is retained while each sample launches a new app process. +- `first-interaction`: daemon stopped and app terminated before each sample, like `cold`; the + sample then opens the app (untimed) and times the first runner-dependent press that follows, + so an open that defers runner readiness shows its cost here rather than in `cold`. Every sample keeps daemon duration and fresh-process wall time separately, the first-tree status, response bytes, target generation, and typed failure details. Each raw result also records the typed host model, model identifier, CPU, and core count needed to compare performance baselines. The raw JSON is validated against `raw-result.schema.v1.json`; the adjacent Markdown is a human-readable summary. diff --git a/scripts/ios-snapshot-benchmark/cell-admission.ts b/scripts/ios-snapshot-benchmark/cell-admission.ts index 7733a26b67..dcd0c01b7d 100644 --- a/scripts/ios-snapshot-benchmark/cell-admission.ts +++ b/scripts/ios-snapshot-benchmark/cell-admission.ts @@ -47,7 +47,7 @@ export function prepareCellState(options: CellAdmissionOptions): void { assertDaemonStopped(options.stateDir); bootSimulator(options.udid); assertSimulatorState(options.udid, 'Booted'); - if (options.state === 'cold') { + if (options.state === 'cold' || options.state === 'first-interaction') { terminateApp(options.udid, options.fixture.app); assertAppStopped(options.udid, options.fixture.app); } @@ -58,7 +58,9 @@ export function prepareSampleState(options: CellAdmissionOptions): void { prepareColdColdState(options); return; } - if (options.state === 'cold') { + if (options.state === 'cold' || options.state === 'first-interaction') { + // The first interaction after an open is measured against a cold runner every time: the + // daemon and its retained runner go away with it, and the app is relaunched by the sample. stopDaemon(options.repoRoot, options.stateDir); assertDaemonStopped(options.stateDir); terminateApp(options.udid, options.fixture.app); diff --git a/scripts/ios-snapshot-benchmark/command.ts b/scripts/ios-snapshot-benchmark/command.ts index 043351b945..8c279eedbb 100644 --- a/scripts/ios-snapshot-benchmark/command.ts +++ b/scripts/ios-snapshot-benchmark/command.ts @@ -62,6 +62,28 @@ export function openFixture( }; } +/** + * The first runner-dependent command after an open, timed on its own: the open itself is untimed + * setup (the `cold` and `relaunch` cells measure it). When the launch URL raises the deep-link + * confirmation, that dialog's `Open` press is the first interaction; otherwise the screen anchor + * is pressed. Either way the sample records whatever runner readiness the open deferred. + */ +export function firstInteractionAfterOpen(context: CliContext, fixture: ScreenFixture): CliResult { + const opened = runCli(context, [ + 'open', + fixture.app, + '--relaunch', + ...(fixture.launchUrl ? ['--launch-url', fixture.launchUrl] : []), + '--foreground', + ]); + if (!opened.ok) return opened; + const selector = + fixture.launchUrl && hasDeepLinkConfirmation(opened.payload) + ? 'label="Open"' + : `text=${JSON.stringify(fixture.anchorText)}`; + return pressFixtureTarget(context, selector); +} + export async function openFixtureAsync( context: CliContext, fixture: ScreenFixture, diff --git a/scripts/ios-snapshot-benchmark/definitions.test.ts b/scripts/ios-snapshot-benchmark/definitions.test.ts index 31370fa9b7..9fbdfba9ca 100644 --- a/scripts/ios-snapshot-benchmark/definitions.test.ts +++ b/scripts/ios-snapshot-benchmark/definitions.test.ts @@ -14,6 +14,8 @@ test('parses the versioned state and screen cells', () => { assert.deepEqual(parseRtt('80,0,20,0'), [80, 0, 20]); assert.equal(sampleMinimumForState('cold-cold'), 10); assert.equal(sampleMinimumForState('relaunch'), 20); + assert.equal(sampleMinimumForState('first-interaction'), 10); + assert.deepEqual(parseLocalStates('first-interaction'), ['first-interaction']); }); test('enforces the warm and cold sample minima', () => { diff --git a/scripts/ios-snapshot-benchmark/definitions.ts b/scripts/ios-snapshot-benchmark/definitions.ts index a96d04a2c7..85106b89f4 100644 --- a/scripts/ios-snapshot-benchmark/definitions.ts +++ b/scripts/ios-snapshot-benchmark/definitions.ts @@ -84,7 +84,7 @@ export function parseLocalStates(value: string | undefined): LocalState[] { .split(',') .map((item) => item.trim()) .filter(Boolean) as LocalState[]; - const valid = new Set(['cold-cold', 'cold', 'warm', 'relaunch']); + const valid = new Set(['cold-cold', 'cold', 'warm', 'relaunch', 'first-interaction']); const unknown = states.filter((state) => !valid.has(state)); if (unknown.length > 0) throw new Error(`Unknown --state value: ${unknown.join(', ')}`); if (states.length === 0) throw new Error('--state requires at least one cell.'); diff --git a/scripts/ios-snapshot-benchmark/local-runner.ts b/scripts/ios-snapshot-benchmark/local-runner.ts index 6dc31a8fec..c926ea62a8 100644 --- a/scripts/ios-snapshot-benchmark/local-runner.ts +++ b/scripts/ios-snapshot-benchmark/local-runner.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { + firstInteractionAfterOpen, openFixture, sampleFromCli, snapshotFixture, @@ -73,14 +74,15 @@ async function runCell(options: CellAdmissionOptions): Promise { function runMeasuredCommand(context: CliContext, options: CellAdmissionOptions): CliResult { if (options.state === 'warm') return snapshotFixture(context); + if (options.state === 'first-interaction') + return firstInteractionAfterOpen(context, options.fixture); return openFixture(context, options.fixture, { relaunch: true }); } -function measuredOperation( - state: LocalState, -): 'open-foreground' | 'snapshot' | 'relaunch-foreground' { +function measuredOperation(state: LocalState): RawSample['operation'] { if (state === 'warm') return 'snapshot'; if (state === 'relaunch') return 'relaunch-foreground'; + if (state === 'first-interaction') return 'first-interaction'; return 'open-foreground'; } diff --git a/scripts/ios-snapshot-benchmark/raw-result.schema.v1.json b/scripts/ios-snapshot-benchmark/raw-result.schema.v1.json index 94c1c3df26..072f989d1d 100644 --- a/scripts/ios-snapshot-benchmark/raw-result.schema.v1.json +++ b/scripts/ios-snapshot-benchmark/raw-result.schema.v1.json @@ -109,7 +109,10 @@ "states": { "type": "array", "minItems": 1, - "items": { "type": "string", "enum": ["cold-cold", "cold", "warm", "relaunch"] } + "items": { + "type": "string", + "enum": ["cold-cold", "cold", "warm", "relaunch", "first-interaction"] + } } } }, @@ -136,7 +139,10 @@ "properties": { "transport": { "type": "string", "enum": ["local", "proxy"] }, "execution": { "type": "string", "enum": ["fresh-process-cli", "persistent-client"] }, - "state": { "type": "string", "enum": ["cold-cold", "cold", "warm", "relaunch"] }, + "state": { + "type": "string", + "enum": ["cold-cold", "cold", "warm", "relaunch", "first-interaction"] + }, "screen": { "type": "string", "enum": ["quiet", "list", "nested-scroll", "alert", "system-surface", "xctest-stress"] @@ -144,7 +150,7 @@ "sampleMinimum": { "type": "integer", "minimum": 1 }, "operation": { "type": "string", - "enum": ["open-foreground", "snapshot", "relaunch-foreground"] + "enum": ["open-foreground", "snapshot", "relaunch-foreground", "first-interaction"] }, "samples": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/sample" } }, "wallClockMs": { "$ref": "#/$defs/summaryOrNull" }, @@ -251,7 +257,7 @@ "finishedAt": { "type": "string", "minLength": 1 }, "operation": { "type": "string", - "enum": ["open-foreground", "snapshot", "relaunch-foreground"] + "enum": ["open-foreground", "snapshot", "relaunch-foreground", "first-interaction"] }, "wallClockMs": { "type": "number", "minimum": 0 }, "daemonDurationMs": { "type": "number", "minimum": 0 }, diff --git a/scripts/ios-snapshot-benchmark/types.ts b/scripts/ios-snapshot-benchmark/types.ts index e8c4d11192..3295e570b1 100644 --- a/scripts/ios-snapshot-benchmark/types.ts +++ b/scripts/ios-snapshot-benchmark/types.ts @@ -8,7 +8,7 @@ export const WARM_SAMPLE_MINIMUM = 20; export const COLD_SAMPLE_MINIMUM = 10; export const PROXY_RTT_VALUES = [0, 20, 80] as const; -export type LocalState = 'cold-cold' | 'cold' | 'warm' | 'relaunch'; +export type LocalState = 'cold-cold' | 'cold' | 'warm' | 'relaunch' | 'first-interaction'; export type Transport = 'local' | 'proxy'; export type Execution = 'fresh-process-cli' | 'persistent-client'; export type ScreenId = @@ -52,7 +52,7 @@ export type RawSample = { index: number; startedAt: string; finishedAt: string; - operation: 'open-foreground' | 'snapshot' | 'relaunch-foreground'; + operation: 'open-foreground' | 'snapshot' | 'relaunch-foreground' | 'first-interaction'; wallClockMs: number; daemonDurationMs?: number; responseBytes?: number; From e319193ccf94dd3bf0ca0c93fe1ec612de5822c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:52:12 +0200 Subject: [PATCH 16/21] bench(ios): read the deep-link confirmation from a snapshot and by node type The open response carries no tree and regular snapshots publish the node type, so the confirmation iOS raises for a launch URL was never seen on this runtime and every deep-linked cell failed its anchor check. --- scripts/ios-snapshot-benchmark/command.ts | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/ios-snapshot-benchmark/command.ts b/scripts/ios-snapshot-benchmark/command.ts index 8c279eedbb..bf253f404c 100644 --- a/scripts/ios-snapshot-benchmark/command.ts +++ b/scripts/ios-snapshot-benchmark/command.ts @@ -51,7 +51,7 @@ export function openFixture( ...(fixture.launchUrl ? ['--launch-url', fixture.launchUrl] : []), '--foreground', ]); - if (!fixture.launchUrl || !hasDeepLinkConfirmation(opened.payload)) return opened; + if (!fixture.launchUrl || !deepLinkConfirmationShown(context, opened)) return opened; const accepted = pressFixtureTarget(context, 'label="Open"'); if (accepted.ok) return opened; return { @@ -78,7 +78,7 @@ export function firstInteractionAfterOpen(context: CliContext, fixture: ScreenFi ]); if (!opened.ok) return opened; const selector = - fixture.launchUrl && hasDeepLinkConfirmation(opened.payload) + fixture.launchUrl && deepLinkConfirmationShown(context, opened) ? 'label="Open"' : `text=${JSON.stringify(fixture.anchorText)}`; return pressFixtureTarget(context, selector); @@ -96,7 +96,15 @@ export async function openFixtureAsync( ...(fixture.launchUrl ? ['--launch-url', fixture.launchUrl] : []), '--foreground', ]); - if (!fixture.launchUrl || !hasDeepLinkConfirmation(opened.payload)) return opened; + if ( + !fixture.launchUrl || + !( + hasDeepLinkConfirmation(opened.payload) || + hasDeepLinkConfirmation((await snapshotFixtureAsync(context)).payload) + ) + ) { + return opened; + } const accepted = await pressFixtureTargetAsync(context, 'label="Open"'); if (accepted.ok) return opened; return { @@ -154,12 +162,23 @@ export function snapshotHasAnchor(payload: unknown, anchorText: string): boolean export function hasDeepLinkConfirmation(payload: unknown): boolean { return snapshotNodes(payload).some((record) => { - const role = readString(record.role); + // Regular snapshots publish the node `type` ('Alert'); older projections used `role`. + const role = (readString(record.role) ?? readString(record.type))?.toLowerCase(); const label = readString(record.label); return role === 'alert' && label?.startsWith('Open in ') === true; }); } +/** + * Whether iOS is asking to confirm the deep link the open just raised. The open response carries + * no tree, so the dialog is read from a snapshot; the caller decides whether that read is setup or + * the measured first interaction. + */ +function deepLinkConfirmationShown(context: CliContext, opened: CliResult): boolean { + if (hasDeepLinkConfirmation(opened.payload)) return true; + return hasDeepLinkConfirmation(snapshotFixture(context).payload); +} + function snapshotNodes(payload: unknown): Record[] { const snapshot = readSnapshotRecord(payload); if (!snapshot || !Array.isArray(snapshot.nodes)) return []; From 7616ba222dd6db1eb5091f1223dc0957f2c038cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 10:55:00 +0200 Subject: [PATCH 17/21] refactor(plan): keep the step-use selectors inside the registry The eager-closure ratchet counts every module the registry loads; the selectors need nothing the registry does not already import, so they live beside find's recording-effect reader instead of adding a module to every entry that loads the registry. --- .../__tests__/find-runtime-execution.test.ts | 3 +- .../snapshot-runtime-execution.test.ts | 3 +- src/core/command-descriptor/registry.ts | 53 ++++++++++++++++++- .../command-descriptor/step-use-selectors.ts | 49 ----------------- 4 files changed, 53 insertions(+), 55 deletions(-) delete mode 100644 src/core/command-descriptor/step-use-selectors.ts diff --git a/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts index c73aee8f03..5adb98c40c 100644 --- a/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/find-runtime-execution.test.ts @@ -1,7 +1,6 @@ +import { commandDescriptors, selectFindStepUses } from '../registry.ts'; import { expect, test } from 'vitest'; import { findRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; -import { selectFindStepUses } from '../step-use-selectors.ts'; -import { commandDescriptors } from '../registry.ts'; test('find descriptor declares its complete runtime uses with no legacy projection', () => { const find = commandDescriptors.find(({ name }) => name === 'find'); diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts index 8def61b6ac..96646bd1a4 100644 --- a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -1,7 +1,6 @@ +import { commandDescriptors, selectSnapshotStepUses } from '../registry.ts'; import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; -import { selectSnapshotStepUses } from '../step-use-selectors.ts'; import { expect, test } from 'vitest'; -import { commandDescriptors } from '../registry.ts'; test('snapshot descriptor declares its complete planned capture uses with no legacy projection', () => { const snapshot = commandDescriptors.find(({ name }) => name === 'snapshot'); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 2f4dca967d..6af4c76ab1 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1,8 +1,17 @@ // The typed-flags request from contracts/, not the daemon's server-side refinement: these // descriptors read `command`, `positionals` and `flags` and never touch `internal`. import type { DispatchedCommand } from '@agent-device/contracts/command'; +import type { + RuntimeUseStep, + RuntimeUseStepSelector, +} from '@agent-device/contracts/command-platform-execution'; import type { RefFrameEffect } from '@agent-device/contracts/replay'; -import { isReadOnlyFindAction, parseFindArgs } from '@agent-device/selectors'; +import { + checkFindArgs, + isReadOnlyFindAction, + parseFindArgs, + type FindAction, +} from '@agent-device/selectors'; import { resolveWaitBudgetMs } from '../wait-positionals.ts'; import { DEFAULT_TIMEOUT_POLICY, @@ -39,6 +48,7 @@ import { clipboardRuntimePlanUses, deviceBootRuntimeUses, fillRuntimeUses, + findRuntimeIntent, findRuntimePlanUses, focusRuntimeUse, gestureRuntimePlanUses, @@ -50,6 +60,8 @@ import { orientationRuntimeUse, perfRuntimePlanUses, pressRuntimeUses, + resolveSelectorCaptureRuntimePlan, + resolveSnapshotRuntimePlan, screenshotRuntimePlanUses, scrollRuntimePlanUses, selectorCaptureRuntimePlanUses, @@ -64,7 +76,6 @@ import { viewportRuntimeUse, waitSelectorCaptureRuntimePlanUses, } from '@agent-device/contracts/platform-runtime-operations'; -import { selectFindStepUses, selectSnapshotStepUses } from './step-use-selectors.ts'; import { assertRecordRuntimeExecution } from '@agent-device/contracts/record-runtime-execution'; import { screenRecordingRuntimePlanUses } from '@agent-device/contracts/screen-recording-runtime-plan'; import { readDeclaredPlatformExecution } from './platform-execution-entry.ts'; @@ -431,6 +442,44 @@ const DEPLOY_APP_COMMAND_DESCRIPTOR = { batchable: true, } as const; +/** + * Plan-time selectors for the commands whose declared alternatives differ in what they execute. + * Each reads the daemon step exactly as its handler will (`flags` and `positionals`; a structured + * `input` only when a caller kept one) and resolves the same plan the handler resolves, for both + * sides of the active-app split the plan cannot know yet. + */ +export const selectSnapshotStepUses: RuntimeUseStepSelector = (step) => { + const customActions = + step.flags?.['snapshotCustomActions'] === true || step.input?.['customActions'] === true; + return [true, false].map( + (hasActiveApp) => resolveSnapshotRuntimePlan({ customActions, hasActiveApp }).use, + ); +}; + +/** + * `find` parses its action from positionals and defaults a missing one to click, so a step the + * handler would not parse as a read-only, focus, or type action keeps every declared alternative + * (fail closed), including a step whose positionals are not there to parse. + */ +export const selectFindStepUses: RuntimeUseStepSelector = (step) => { + const action = findStepAction(step); + if (action === undefined || !plansOwnLeg(action)) return findRuntimePlanUses; + const intent = findRuntimeIntent(action); + return [true, false].map( + (hasActiveApp) => resolveSelectorCaptureRuntimePlan({ hasActiveApp, intent }).use, + ); +}; + +function plansOwnLeg(action: FindAction['kind']): boolean { + return isReadOnlyFindAction(action) || action === 'focus' || action === 'type'; +} + +function findStepAction(step: RuntimeUseStep): FindAction['kind'] | undefined { + if (step.positionals === undefined) return undefined; + const checked = checkFindArgs(step.positionals, step.flags); + return checked.ok ? checked.parsed.action : undefined; +} + export const RAW_COMMAND_DESCRIPTORS = [ { name: 'human_control', diff --git a/src/core/command-descriptor/step-use-selectors.ts b/src/core/command-descriptor/step-use-selectors.ts deleted file mode 100644 index c5b2272f04..0000000000 --- a/src/core/command-descriptor/step-use-selectors.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - RuntimeUseStep, - RuntimeUseStepSelector, -} from '@agent-device/contracts/command-platform-execution'; -import { - findRuntimeIntent, - findRuntimePlanUses, - resolveSelectorCaptureRuntimePlan, - resolveSnapshotRuntimePlan, -} from '@agent-device/contracts/platform-runtime-operations'; -import { checkFindArgs, isReadOnlyFindAction, type FindAction } from '@agent-device/selectors'; - -/** - * Plan-time selectors for the commands whose declared alternatives differ in what they execute. - * Each reads the daemon step exactly as its handler will (`flags` and `positionals`; a structured - * `input` only when a caller kept one) and resolves the same plan the handler resolves, for both - * sides of the active-app split the plan cannot know yet. - */ -export const selectSnapshotStepUses: RuntimeUseStepSelector = (step) => { - const customActions = - step.flags?.['snapshotCustomActions'] === true || step.input?.['customActions'] === true; - return [true, false].map( - (hasActiveApp) => resolveSnapshotRuntimePlan({ customActions, hasActiveApp }).use, - ); -}; - -/** - * `find` parses its action from positionals and defaults a missing one to click, so a step the - * handler would not parse as a read-only, focus, or type action keeps every declared alternative - * (fail closed), including a step whose positionals are not there to parse. - */ -export const selectFindStepUses: RuntimeUseStepSelector = (step) => { - const action = findStepAction(step); - if (action === undefined || !plansOwnLeg(action)) return findRuntimePlanUses; - const intent = findRuntimeIntent(action); - return [true, false].map( - (hasActiveApp) => resolveSelectorCaptureRuntimePlan({ hasActiveApp, intent }).use, - ); -}; - -function plansOwnLeg(action: FindAction['kind']): boolean { - return isReadOnlyFindAction(action) || action === 'focus' || action === 'type'; -} - -function findStepAction(step: RuntimeUseStep): FindAction['kind'] | undefined { - if (step.positionals === undefined) return undefined; - const checked = checkFindArgs(step.positionals, step.flags); - return checked.ok ? checked.parsed.action : undefined; -} From 9189275dcb9aa95d14b8a71ab89db814266dcd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 11:50:14 +0200 Subject: [PATCH 18/21] feat(apple): release a speculative runner when the plan is proven observation-only #2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a prewarm started that no command has used yet is speculative: the session records that mark at creation, the first command that is not a readiness probe clears it, and a Simulator open whose plan is proven observation-only asks the runner owner to release a speculative session in the background, so the observation path never waits for a runner to stop either. A runner that has served a command is the session's working runner and stays under the existing idle-stop policy, so a mixed workload does not pay a cold runner start at every observation-only open. The release goes through the runner provider seam: the local provider stops its own speculative session; a provider that never starts speculative work omits the operation and releases nothing. --- .fallowrc.json | 1 + CONTEXT.md | 2 +- .../src/application-lifecycle-runtime.ts | 14 +- .../platform-apple/src/core/runner-client.ts | 2 + packages/platform-apple/src/lifecycle.test.ts | 49 +++-- packages/platform-apple/src/lifecycle.ts | 2 + packages/platform-apple/src/open-policy.ts | 17 ++ .../src/runner-operations-facade.ts | 1 + .../runner-session-speculative.test.ts | 180 ++++++++++++++++++ packages/platform-apple/src/runner/client.ts | 3 + .../src/runner/runner-client.ts | 15 +- .../src/runner/runner-lifecycle.ts | 2 + .../src/runner/runner-provider.ts | 12 +- .../src/runner/runner-session-types.ts | 5 + .../src/runner/runner-session.ts | 25 +++ .../platform-apple/src/runtime.fixtures.ts | 1 + ...latform-runtime-apple-application-tools.ts | 6 + 17 files changed, 315 insertions(+), 22 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts diff --git a/.fallowrc.json b/.fallowrc.json index acc7129e14..f8a3f41fe6 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -97,6 +97,7 @@ "exports": [ "detachIosSimulatorRunnerSessionsForShutdown", "hasLiveIosRunnerSession", + "releaseSpeculativeIosRunnerSessionFor", "stopAllIosRunnerSessions" ] }, diff --git a/CONTEXT.md b/CONTEXT.md index 05e0de4382..673d36465f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -125,7 +125,7 @@ The daemon-side truth for route ownership and request-policy traits. Per-command classes steering Apple runner lifecycle and recovery, independent of the public surface. **Runner demand**: -What a local-Simulator open prepares of the XCTest runner for the remaining batch steps. +What a Simulator open prepares, or releases unused, of the XCTest runner for remaining steps. **Daemon RPC protocol version**: The integer that detects breaking compatibility across the remote daemon boundary. diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 408c63d420..492e14909d 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -77,10 +77,11 @@ export type OpenApplicationPreparationInput = Readonly<{ /** * How much the platform's interaction host (the XCTest runner on iOS) is known to be needed by * the plan that contains an open. `none`: every following step is proven observation-only, so this - * open starts no runner (an already-live runner stays under the existing idle-stop policy). - * `possible`: the plan is unknown, so a speculative prewarm may run but observation never awaits - * it. `required`: a following step needs the runner, so readiness is prepared now and awaited by - * that step. + * open starts no runner and releases a speculative one (started by an earlier prewarm, used by no + * command); a runner that has served a command is the session's and stays under the idle-stop + * policy. `possible`: the plan is unknown, so a speculative prewarm may run but observation never + * awaits it. `required`: a following step needs the runner, so readiness is prepared now and + * awaited by that step. */ export type OpenApplicationRunnerDemand = 'none' | 'possible' | 'required'; @@ -304,6 +305,11 @@ export type AppleApplicationTools = Readonly<{ device: DeviceInfo, execution: Readonly<{ requestId?: string }>, ): Promise; + /** Stops a runner a prewarm started that no command has used; true when one was stopped. */ + releaseSpeculativeRunner( + device: DeviceInfo, + execution: Readonly<{ requestId?: string }>, + ): Promise; scheduleRunnerIdleStop(deviceId: string): void; prepareRunner( device: DeviceInfo, diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 09ce610428..4e9388555b 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -22,6 +22,8 @@ export const notifyIosRunnerAppRelaunched: AppleRunnerClient['notifyIosRunnerApp client.notifyIosRunnerAppRelaunched; export const hasLiveIosRunnerSession: AppleRunnerClient['hasLiveIosRunnerSession'] = client.hasLiveIosRunnerSession; +export const releaseSpeculativeIosRunnerSessionFor: AppleRunnerClient['releaseSpeculativeIosRunnerSessionFor'] = + client.releaseSpeculativeIosRunnerSessionFor; export const prewarmAppleRunnerCache: AppleRunnerClient['prewarmAppleRunnerCache'] = client.prewarmAppleRunnerCache; export const prewarmIosRunnerSession: AppleRunnerClient['prewarmIosRunnerSession'] = diff --git a/packages/platform-apple/src/lifecycle.test.ts b/packages/platform-apple/src/lifecycle.test.ts index becfbe612a..15e2cb50b2 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -294,6 +294,10 @@ function simulatorHost(overrides: { overrides.events.push('reset'); }); const hasLiveRunnerSession = vi.fn(overrides.hasLiveRunnerSession ?? (async () => false)); + const releaseSpeculativeRunner = vi.fn(async () => { + overrides.events.push('release'); + return true; + }); const host = { ...baseHost, clock: { ...baseHost.clock, sleep: async () => {} }, @@ -303,14 +307,22 @@ function simulatorHost(overrides: { prewarmRunnerSession, notifyRunnerAppRelaunched, hasLiveRunnerSession, + releaseSpeculativeRunner, }, } as unknown as PlatformRuntimeHost; - return { host, prewarmRunnerSession, notifyRunnerAppRelaunched, hasLiveRunnerSession }; + return { + host, + prewarmRunnerSession, + notifyRunnerAppRelaunched, + hasLiveRunnerSession, + releaseSpeculativeRunner, + }; } -test('a Simulator open whose plan is observation-only starts no runner and reports demand none', async () => { +test('a Simulator open whose plan is observation-only starts no runner, releases a speculative one, and reports demand none', async () => { const events: string[] = []; - const { host, prewarmRunnerSession, notifyRunnerAppRelaunched } = simulatorHost({ events }); + const { host, prewarmRunnerSession, notifyRunnerAppRelaunched, releaseSpeculativeRunner } = + simulatorHost({ events }); const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, @@ -326,7 +338,11 @@ test('a Simulator open whose plan is observation-only starts no runner and repor expect(outcome.timing.runnerPrewarmScheduled).toBeUndefined(); expect(prewarmRunnerSession).not.toHaveBeenCalled(); expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); - expect(events).toEqual(['open']); + // The release goes to the runner owner before the app opens and is never awaited by the open. + expect(releaseSpeculativeRunner).toHaveBeenCalledExactlyOnceWith(simulator, { + plannedOperations: ['captureSnapshot', 'findText', 'captureScreenshot'], + }); + expect(events).toEqual(['release', 'open']); }); test.each([ @@ -337,15 +353,16 @@ test.each([ async (_name, plan, expectedDemand) => { const events: string[] = []; let releasePrewarm = () => {}; - const { host, prewarmRunnerSession, notifyRunnerAppRelaunched } = simulatorHost({ - events, - // A prewarm that never finishes inside the open: if the open awaited runner readiness - // this test would time out instead of passing. - prewarmRunnerSession: () => - new Promise((resolve) => { - releasePrewarm = resolve; - }), - }); + const { host, prewarmRunnerSession, notifyRunnerAppRelaunched, releaseSpeculativeRunner } = + simulatorHost({ + events, + // A prewarm that never finishes inside the open: if the open awaited runner readiness + // this test would time out instead of passing. + prewarmRunnerSession: () => + new Promise((resolve) => { + releasePrewarm = resolve; + }), + }); const lifecycle = bindAppleApplicationLifecycle({ host, device: simulator, @@ -373,6 +390,8 @@ test.each([ expect(prewarmRunnerSession).toHaveBeenCalledOnce(); // The starting runner has no cached target, so nothing is reset and nothing is awaited. expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); + // Only a proven observation-only plan releases; a plan that may need the runner keeps it. + expect(releaseSpeculativeRunner).not.toHaveBeenCalled(); expect(events).toEqual(['open']); }, ); @@ -459,7 +478,7 @@ test('a Simulator open lets the launched app become observable instead of sleepi expect(awaitObservable).toHaveBeenCalledWith(simulator, 'com.example.app', signal); expect(outcome.timing.postOpenObservation).toBe('observable'); - expect(events).toEqual(['open', 'observe']); + expect(events).toEqual(['release', 'open', 'observe']); }); test('a Simulator whose bridge cannot answer keeps the fixed settle', async () => { @@ -481,7 +500,7 @@ test('a Simulator whose bridge cannot answer keeps the fixed settle', async () = }); expect(outcome.timing.postOpenObservation).toBe('unobservable'); - expect(events).toEqual(['open', 'sleep']); + expect(events).toEqual(['release', 'open', 'sleep']); }); test('a tvOS Simulator relaunch keeps the awaited prewarm and asks for no observation', async () => { diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 0a5ace4c78..9f3a593d8d 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -21,6 +21,7 @@ import { resolveRunnerPrewarmPolicy, settleAppleOpen, type MutableOpenTiming, + releaseSpeculativeRunner, } from './open-policy.ts'; import type { LaunchObservationPort } from './snapshot-observability.ts'; import { isApplePlatform, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; @@ -99,6 +100,7 @@ async function openAppleApplication( const runner = createRunnerPrewarm(host, binding, input, timing); const policy = resolveRunnerPrewarmPolicy(binding.device, input, localIosSimulator); if (policy.runnerDemand) timing.runnerDemand = policy.runnerDemand; + releaseSpeculativeRunner(host, binding, input, policy); const { shouldPrewarmRunner } = policy; const retainRunnerForRelaunch = shouldRetainRunnerForRelaunch( binding.device, diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index d6b6104d53..df7608e794 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -50,6 +50,23 @@ export function resolveRunnerPrewarmPolicy( }; } +/** + * A proven observation-only plan keeps no runner it did not ask for: a speculative one (an earlier + * prewarm no command has used) is released through the runner owner, in the background so the + * observation path never waits for a runner to stop either. + */ +export function releaseSpeculativeRunner( + host: Pick, + binding: Readonly<{ device: DeviceInfo }>, + input: OpenApplicationInput, + policy: RunnerPrewarmPolicy, +): void { + if (policy.runnerDemand !== 'none') return; + void host.appleApplications + .releaseSpeculativeRunner(binding.device, input.execution) + .catch(() => {}); +} + /** * Lets the opened app become observable before the open returns. A local Simulator asks its AX * bridge, bounded by the launch-transition windows the bridge itself defines, so the first diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index e27bfb41b6..40a66663a0 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -8,6 +8,7 @@ export { prewarmAppleRunnerCache, prewarmIosRunnerSession, readStaleRunnerLease, + releaseSpeculativeIosRunnerSessionFor, resolveRunnerAppBundleId, runAppleRunnerCommand, scheduleIosRunnerIdleStop, diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts new file mode 100644 index 0000000000..909bd7645a --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { + makeBackgroundRunner, + makeClassifyOwnerLivenessViaMocks, + runnerResponse, +} from './runner-session-fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const { + mockAcquireXcodebuildSimulatorSetRedirect, + mockCleanupTempFile, + mockEnsureXctestrunArtifact, + mockGetFreePort, + mockIsProcessAlive, + mockIsProcessGroupAlive, + mockPrepareXctestrunWithEnv, + mockReadProcessCommand, + mockReadProcessStartTime, + mockResolveExpectedRunnerCacheMetadata, + mockResolveRunnerDerivedPath, + mockRunAppleToolCommand, + mockRunCmdBackground, + mockRunXcrun, + mockSendRunnerCommandOnce, + mockSignalPidsBestEffort, + mockSignalProcessGroupBestEffort, + mockWaitForRunner, + mockRedirectRelease, +} = vi.hoisted(() => ({ + mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(), + mockCleanupTempFile: vi.fn(), + mockEnsureXctestrunArtifact: vi.fn(), + mockGetFreePort: vi.fn(), + mockIsProcessAlive: vi.fn(), + mockIsProcessGroupAlive: vi.fn(), + mockPrepareXctestrunWithEnv: vi.fn(), + // Deterministic owner identity: the real readProcessStartTime shells out to `ps` with a 1s + // timeout that can miss under CPU contention and flip a live owner to dead. + mockReadProcessCommand: vi.fn((_pid: number) => null as string | null), + mockReadProcessStartTime: vi.fn((_pid: number) => 'fixed-test-owner-start-time' as string | null), + mockResolveExpectedRunnerCacheMetadata: vi.fn(), + mockResolveRunnerDerivedPath: vi.fn(), + mockRunAppleToolCommand: vi.fn(), + mockRunCmdBackground: vi.fn(), + mockRunXcrun: vi.fn(), + mockSendRunnerCommandOnce: vi.fn(), + // The runner child pid is fabricated (4242): signal writes are mocked next to the liveness + // reads so a made-up pid never reaches a sibling vitest fork (#1824). + mockSignalPidsBestEffort: vi.fn(), + mockSignalProcessGroupBestEffort: vi.fn(), + mockWaitForRunner: vi.fn(), + mockRedirectRelease: vi.fn(), +})); + +vi.mock('../runner-io.ts', async () => { + const actual = await vi.importActual('../runner-io.ts'); + return { ...actual, cleanupTempFile: mockCleanupTempFile, getFreePort: mockGetFreePort }; +}); + +vi.mock('../runner-transport.ts', async () => { + const actual = + await vi.importActual('../runner-transport.ts'); + return { ...actual, sendRunnerCommandOnce: mockSendRunnerCommandOnce }; +}); + +vi.mock('../runner-xctestrun.ts', async () => { + const actual = + await vi.importActual('../runner-xctestrun.ts'); + return { + ...actual, + acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect, + ensureXctestrunArtifact: mockEnsureXctestrunArtifact, + prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv, + resolveExpectedRunnerCacheMetadata: mockResolveExpectedRunnerCacheMetadata, + resolveRunnerDerivedPath: mockResolveRunnerDerivedPath, + }; +}); + +vi.mock('../runner-startup-transport.ts', async () => { + const actual = await vi.importActual( + '../runner-startup-transport.ts', + ); + return { ...actual, waitForRunner: mockWaitForRunner }; +}); + +import { + abortAllIosRunnerSessions, + ensureRunnerSession, + getRunnerSessionSnapshot, + markRunnerSessionServed, + releaseSpeculativeIosRunnerSession, +} from '../runner-session.ts'; +import { withRunnerCommandId } from '../runner-contract.ts'; + +const TEST_OWNER_START_TIME = 'fixed-test-owner-start-time'; + +// Split from runner-session.test.ts, which is over the test-size tripwire: the speculative +// session family (#2198: a proven observation-only plan retains no runner it did not ask for) +// answers its own domain question here with the same seam scaffolding. +beforeEach(async () => { + appleRunnerTestHost.update({ + runCmdBackground: mockRunCmdBackground, + isProcessAlive: mockIsProcessAlive, + isProcessGroupAlive: mockIsProcessGroupAlive, + readProcessCommand: mockReadProcessCommand, + readProcessStartTime: mockReadProcessStartTime, + signalPidsBestEffort: mockSignalPidsBestEffort, + signalProcessGroupBestEffort: mockSignalProcessGroupBestEffort, + runAppleToolCommand: mockRunAppleToolCommand, + runXcrun: mockRunXcrun, + leaseOwnerStateDir: () => undefined, + classifyOwnerLiveness: makeClassifyOwnerLivenessViaMocks({ + isProcessAlive: (pid) => Boolean(mockIsProcessAlive(pid)), + readProcessStartTime: (pid) => (mockReadProcessStartTime(pid) as string | null) ?? null, + }), + }); + await abortAllIosRunnerSessions(); + vi.resetAllMocks(); + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = mkdtempForTestSync( + 'agent-device-runner-lease-test-', + ); + mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockEnsureXctestrunArtifact.mockResolvedValue({ + xctestrunPath: '/tmp/base-runner.xctestrun', + derived: '/tmp/derived', + cache: 'hit', + artifact: 'reused', + buildMs: 0, + xctestrunPathSource: 'cache', + }); + mockGetFreePort.mockResolvedValue(8123); + mockPrepareXctestrunWithEnv.mockResolvedValue({ + xctestrunPath: '/tmp/session-runner.xctestrun', + jsonPath: '/tmp/session-runner.json', + }); + mockResolveExpectedRunnerCacheMetadata.mockReturnValue({ schemaVersion: 1 }); + mockResolveRunnerDerivedPath.mockReturnValue('/tmp/derived'); + mockAcquireXcodebuildSimulatorSetRedirect.mockResolvedValue({ release: mockRedirectRelease }); + mockRunCmdBackground.mockReturnValue(makeBackgroundRunner(4242)); + mockRunAppleToolCommand.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + mockIsProcessAlive.mockReturnValue(true); + mockIsProcessGroupAlive.mockReturnValue(false); + mockReadProcessCommand.mockReturnValue(null); + mockReadProcessStartTime.mockImplementation((pid: number) => + pid === process.pid ? TEST_OWNER_START_TIME : null, + ); + mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); +}); + +test('a prewarm-started session is speculative until a command other than a readiness probe uses it', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-speculative-sim' }; + + const session = await ensureRunnerSession(device, { speculative: true }); + assert.equal(session.speculative, true); + + markRunnerSessionServed(session, withRunnerCommandId({ command: 'uptime' })); + assert.equal(session.speculative, true, 'a readiness probe is not a use'); + + markRunnerSessionServed(session, withRunnerCommandId({ command: 'tap', x: 1, y: 1 })); + assert.equal(session.speculative, false); + assert.equal(await releaseSpeculativeIosRunnerSession(device.id), false); + assert.notEqual(getRunnerSessionSnapshot(device.id), null, 'a served runner stays'); +}); + +test('releasing a speculative session stops it; a session a command asked for is kept', async () => { + const speculative = { ...IOS_SIMULATOR, id: 'runner-session-speculative-release-sim' }; + const demanded = { ...IOS_SIMULATOR, id: 'runner-session-demanded-sim' }; + + await ensureRunnerSession(speculative, { speculative: true }); + await ensureRunnerSession(demanded, {}); + + assert.equal(await releaseSpeculativeIosRunnerSession(speculative.id), true); + assert.equal(getRunnerSessionSnapshot(speculative.id), null); + assert.equal(await releaseSpeculativeIosRunnerSession(demanded.id), false); + assert.notEqual(getRunnerSessionSnapshot(demanded.id), null); + assert.equal(await releaseSpeculativeIosRunnerSession('no-such-device'), false); +}); diff --git a/packages/platform-apple/src/runner/client.ts b/packages/platform-apple/src/runner/client.ts index 32854c3441..10527d952c 100644 --- a/packages/platform-apple/src/runner/client.ts +++ b/packages/platform-apple/src/runner/client.ts @@ -1,6 +1,7 @@ import { bindAppleRunnerHost, type AppleRunnerHost } from './host.ts'; import { hasLiveIosRunnerSession, + releaseSpeculativeIosRunnerSessionFor, notifyIosRunnerAppRelaunched, prepareIosRunner, prewarmAppleRunnerCache, @@ -35,6 +36,7 @@ export type AppleRunnerClient = { runAppleRunnerCommand: typeof runAppleRunnerCommand; notifyIosRunnerAppRelaunched: typeof notifyIosRunnerAppRelaunched; hasLiveIosRunnerSession: typeof hasLiveIosRunnerSession; + releaseSpeculativeIosRunnerSessionFor: typeof releaseSpeculativeIosRunnerSessionFor; prewarmAppleRunnerCache: typeof prewarmAppleRunnerCache; prewarmIosRunnerSession: typeof prewarmIosRunnerSession; prepareIosRunner: typeof prepareIosRunner; @@ -65,6 +67,7 @@ export function createAppleRunnerClient(host: AppleRunnerHost): AppleRunnerClien runAppleRunnerCommand, notifyIosRunnerAppRelaunched, hasLiveIosRunnerSession, + releaseSpeculativeIosRunnerSessionFor, prewarmAppleRunnerCache, prewarmIosRunnerSession, prepareIosRunner, diff --git a/packages/platform-apple/src/runner/runner-client.ts b/packages/platform-apple/src/runner/runner-client.ts index 79a0cca2ca..eb5eb6d4dd 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -5,6 +5,7 @@ import { getRunnerSessionSnapshot, stopIosRunnerSession, validateRunnerDevice, + releaseSpeculativeIosRunnerSession, } from './runner-session.ts'; import { assertRunnerRequestActive, @@ -191,20 +192,32 @@ export function hasLiveIosRunnerSession( return resolveAppleRunnerRuntime(device, options).hasLiveSession(device); } +/** Releases the runner a prewarm started for `device` if no command has used it; false otherwise. */ +export async function releaseSpeculativeIosRunnerSessionFor( + device: DeviceInfo, + options: { requestId?: string } = {}, +): Promise { + if (!isIosFamily(device)) return false; + const release = resolveAppleRunnerRuntime(device, options).releaseSpeculativeSession; + return release ? await release(device) : false; +} + const LOCAL_APPLE_RUNNER_RUNTIME = createLocalAppleRunnerProvider(executeRunnerCommand, { prepare: prepareLocalIosRunner, hasLiveSession: (device) => { const session = getRunnerSessionSnapshot(device.id); return session !== null && session.alive && session.ready; }, + releaseSpeculativeSession: async (device) => await releaseSpeculativeIosRunnerSession(device.id), prewarm: async (device, options) => { const { healthCheck, ...runnerOptions } = options; if (healthCheck === false) { - await ensureRunnerSession(device, runnerOptions); + await ensureRunnerSession(device, { ...runnerOptions, speculative: true }); return; } await prepareLocalIosRunner(device, { ...runnerOptions, + speculative: true, healthTimeoutMs: RUNNER_COMMAND_TIMEOUT_MS, }); }, diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 10cac51d76..1644092c78 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -11,6 +11,7 @@ import { invalidateRunnerSession, executeRunnerCommandWithSession, readRunnerStartupTimeoutMs, + markRunnerSessionServed, } from './runner-session.ts'; import { assertRunnerRequestActive, @@ -275,6 +276,7 @@ export async function executeRunnerCommand( } session = await ensureRunnerSession(device, options); assertExpectedRunnerSession(session, options.expectedRunnerSessionId); + markRunnerSessionServed(session, command); if (recycleBootBegun) { commitRunnerRecycle(recycleKey); } diff --git a/packages/platform-apple/src/runner/runner-provider.ts b/packages/platform-apple/src/runner/runner-provider.ts index b54c52efd4..d76bbf3385 100644 --- a/packages/platform-apple/src/runner/runner-provider.ts +++ b/packages/platform-apple/src/runner/runner-provider.ts @@ -19,6 +19,8 @@ export type AppleRunnerCommandOptions = AppleRunnerRequestOptions & { export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { buildTimeoutMs?: number; forceRunnerXctestrunRebuild?: boolean; + /** The session is started ahead of any command that needs it (a prewarm). */ + speculative?: boolean; }; export type AppleRunnerPrewarmOptions = AppleRunnerLifecycleOptions & { @@ -79,6 +81,11 @@ export type AppleRunnerProvider = { * states it: registered is not ready, and a provider with no startup cost says so explicitly. */ hasLiveSession: (device: DeviceInfo) => boolean; + /** + * Stops a session this provider started speculatively (a prewarm no command has used yet). + * A provider that never starts speculative work has nothing to release and omits this. + */ + releaseSpeculativeSession?: (device: DeviceInfo) => Promise; }; export type AppleRunnerProviderScopeOptions = { @@ -96,7 +103,10 @@ const appleRunnerProviderScope = new AsyncLocalStorage export function createLocalAppleRunnerProvider( runCommand: AppleRunnerCommandExecutor, - lifecycle: Pick, + lifecycle: Pick< + AppleRunnerProvider, + 'prepare' | 'prewarm' | 'hasLiveSession' | 'releaseSpeculativeSession' + >, ): AppleRunnerProvider { return { runCommand, ...lifecycle }; } diff --git a/packages/platform-apple/src/runner/runner-session-types.ts b/packages/platform-apple/src/runner/runner-session-types.ts index 4901e91c04..d81af23093 100644 --- a/packages/platform-apple/src/runner/runner-session-types.ts +++ b/packages/platform-apple/src/runner/runner-session-types.ts @@ -31,6 +31,11 @@ export type RunnerSession = { // healthy (parsed ok, non-runnerFatal) for a given app bundle. Lives only on // the session object so it dies with every invalidation/restart (#702). lastHealthyMutation?: { atMs: number; appBundleId?: string }; + /** + * Started by a prewarm and not yet used by any command. A proven observation-only plan may + * release it; the first real command clears the mark and the session stays under idle-stop. + */ + speculative?: boolean; startupTimings?: Record; startupTimingsReported?: boolean; logicalLeaseContext?: RunnerLogicalLeaseContext; diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index a65797d36c..e1fc58f338 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -259,6 +259,7 @@ async function startRunnerSessionWithLease( logicalLeaseContext, simulatorSetRedirect: simulatorSetRedirect ?? undefined, lease, + speculative: options.speculative === true, }; if (signal?.aborted) { await disposeRunnerSession(session, { @@ -522,6 +523,30 @@ function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = process.env): number { return RUNNER_RETAINED_IDLE_STOP_DEFAULT_MS; } +/** The first command that is not a readiness probe makes the session the caller's, not a guess. */ +export function markRunnerSessionServed(session: RunnerSession, command: RunnerCommand): void { + if (session.speculative && !isRunnerReadinessProbeCommand(command)) { + session.speculative = false; + } +} + +/** + * Stops the runner a prewarm started when no command has used it yet, so a proven + * observation-only plan retains nothing it did not ask for. A runner that served a command is + * the session's working runner and stays under the idle-stop policy. + */ +export async function releaseSpeculativeIosRunnerSession(deviceId: string): Promise { + const session = runnerSessions.get(deviceId); + if (!session?.speculative) return false; + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_speculative_released', + data: { deviceId, sessionId: session.sessionId, ready: session.ready }, + }); + await stopIosRunnerSession(deviceId); + return true; +} + export async function stopIosRunnerSession(deviceId: string): Promise { cancelIosRunnerIdleStop(deviceId); await withRunnerSessionLock(deviceId, async () => { diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index 9ad2675b48..8b430a1076 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -43,6 +43,7 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { notifyRunnerAppRelaunched: async () => {}, stopRunnerSession: async () => {}, hasLiveRunnerSession: async () => false, + releaseSpeculativeRunner: async () => false, scheduleRunnerIdleStop: () => {}, prepareRunner: async () => ({ runner: {}, connectMs: 0, healthCheckMs: 0 }), applyRuntimeHints: async () => {}, diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 0cf0d87b79..321e287871 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -80,6 +80,12 @@ export function createAppleApplicationTools(): AppleApplicationTools { const { hasLiveIosRunnerSession } = await loadRunnerOperations(); return hasLiveIosRunnerSession(device, { requestId: execution.requestId }); }, + releaseSpeculativeRunner: async (device, execution) => { + const { releaseSpeculativeIosRunnerSessionFor } = await loadRunnerOperations(); + return await releaseSpeculativeIosRunnerSessionFor(device, { + requestId: execution.requestId, + }); + }, scheduleRunnerIdleStop: (deviceId) => { void loadRunnerOperations().then(({ scheduleIosRunnerIdleStop }) => scheduleIosRunnerIdleStop(deviceId), From adec8822479fc3e14929ed7740cedcec1e2412bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 13:25:22 +0200 Subject: [PATCH 19/21] bench(ios): press an unambiguous target on the catalog and Settings screens The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS Settings screens names two actionable elements (the native tab and the screen title); the CLI refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything. Each such screen now names the element the cell presses. --- scripts/ios-snapshot-benchmark/command.ts | 2 +- scripts/ios-snapshot-benchmark/definitions.ts | 2 ++ scripts/ios-snapshot-benchmark/types.ts | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/ios-snapshot-benchmark/command.ts b/scripts/ios-snapshot-benchmark/command.ts index bf253f404c..454fe2f46d 100644 --- a/scripts/ios-snapshot-benchmark/command.ts +++ b/scripts/ios-snapshot-benchmark/command.ts @@ -80,7 +80,7 @@ export function firstInteractionAfterOpen(context: CliContext, fixture: ScreenFi const selector = fixture.launchUrl && deepLinkConfirmationShown(context, opened) ? 'label="Open"' - : `text=${JSON.stringify(fixture.anchorText)}`; + : (fixture.interactionTarget ?? `text=${JSON.stringify(fixture.anchorText)}`); return pressFixtureTarget(context, selector); } diff --git a/scripts/ios-snapshot-benchmark/definitions.ts b/scripts/ios-snapshot-benchmark/definitions.ts index 85106b89f4..4a8b28411c 100644 --- a/scripts/ios-snapshot-benchmark/definitions.ts +++ b/scripts/ios-snapshot-benchmark/definitions.ts @@ -29,6 +29,7 @@ const SCREEN_FIXTURES: readonly ScreenFixture[] = [ app: FIXTURE_APP_ID, launchUrl: `${FIXTURE_SCHEME}/catalog`, anchorText: 'Catalog', + interactionTarget: 'id="catalog-search"', }, { id: 'nested-scroll', @@ -51,6 +52,7 @@ const SCREEN_FIXTURES: readonly ScreenFixture[] = [ label: 'iOS Settings system surface', app: IOS_SETTINGS_APP_ID, anchorText: 'Settings', + interactionTarget: 'text="General"', }, { id: 'xctest-stress', diff --git a/scripts/ios-snapshot-benchmark/types.ts b/scripts/ios-snapshot-benchmark/types.ts index 3295e570b1..e807e819fe 100644 --- a/scripts/ios-snapshot-benchmark/types.ts +++ b/scripts/ios-snapshot-benchmark/types.ts @@ -39,6 +39,11 @@ export type ScreenFixture = { anchorText: string; postSetupAnchorText?: string; setupAction?: 'open-alert'; + /** + * What the first-interaction cell presses when the anchor text names more than one + * actionable element (a native tab and the screen title share it); the anchor otherwise. + */ + interactionTarget?: string; }; export type Failure = { From e729321dcc4797e5e497fb548719e02939589391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 6 Sep 2026 19:05:42 +0200 Subject: [PATCH 20/21] fix(ios): keep observation on the bridge while app discovery is pending and no runner is live #2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no live runner now stays on the single-flight discovery, one wait slice at a time, until the discovery's own deadline or the request signal ends it; a runner that is already live still takes the fallback at once, the cheaper route #2331 chose. --- .../platform-apple/src/snapshot-route.test.ts | 71 ++++++++++++++++++- packages/platform-apple/src/snapshot-route.ts | 34 ++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index ac34575973..6c4e8280f8 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -204,9 +204,10 @@ test('cancelled acquisition does not start a fallback after the request aborts', expect(fallback).not.toHaveBeenCalled(); }); -test('a slow app discovery yields to the XCTest fallback within its wait slice, then serves the bridge', async () => { +test('a slow app discovery yields to a live runner within its wait slice, then serves the bridge', async () => { // The production resolver over a simctl whose `launchctl list` answers only when released, - // the shape of a loaded CI host: the first capture must not sit on that probe. + // the shape of a loaded CI host: with a runner that can answer at once, the first capture + // must not sit on that probe. let release!: () => void; const released = new Promise((resolve) => { release = resolve; @@ -232,9 +233,11 @@ test('a slow app discovery yields to the XCTest fallback within its wait slice, producer: 'simulator-ax-bridge' as const, nodes: [{ index: 0, type: 'Application' }], })); + const baseHost = platformRuntimeHostFixture(); const route = createAppleSnapshotRoute( { - ...platformRuntimeHostFixture(), + ...baseHost, + appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession: async () => true }, snapshot: { captureSurface: vi.fn(), presentIosAcquisition }, }, { source, resolveTarget: createSimulatorSnapshotTargetResolver() }, @@ -302,3 +305,65 @@ function runnerResult() { function signal(): AbortSignal { return new AbortController().signal; } + +test('a slow app discovery keeps observation on the bridge while no runner can answer', async () => { + // #2198: the open no longer awaits the runner, so right after a relaunch the fallback would + // wait for a cold runner start. A capture with no live runner rides the single-flight + // discovery instead, however many wait slices that takes. + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const run = vi.fn(async (args: string[]) => { + if (args[0] === 'spawn') await released; + return { + stdout: + args[0] === 'spawn' + ? `42\t0\tUIKitApplication:${input.options.appBundleId}[launch-a][rb-legacy]` + : JSON.stringify({ + devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] }, + }), + stderr: '', + exitCode: 0, + }; + }); + const runCommand = vi.fn(async () => ({ stdout: 'start-a', stderr: '', exitCode: 0 })); + const fallback = vi.fn(async () => runnerResult()); + const source = sourceReturning(bridgeAcquisition()); + const presentIosAcquisition = vi.fn(async () => ({ + backend: 'xctest' as const, + producer: 'simulator-ax-bridge' as const, + nodes: [{ index: 0, type: 'Application' }], + })); + const hasLiveRunnerSession = vi.fn(async () => false); + const baseHost = platformRuntimeHostFixture(); + const route = createAppleSnapshotRoute( + { + ...baseHost, + appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession }, + snapshot: { captureSurface: vi.fn(), presentIosAcquisition }, + }, + { source, resolveTarget: createSimulatorSnapshotTargetResolver() }, + ); + vi.useFakeTimers(); + try { + await withAppleToolProvider( + createLocalAppleToolProvider({ simctl: { run }, runCommand }), + async () => { + const capture = route.capture(ios, input, signal(), fallback); + await vi.advanceTimersByTimeAsync(4_500); + expect(fallback).not.toHaveBeenCalled(); + expect(hasLiveRunnerSession).toHaveBeenCalled(); + + release(); + await vi.advanceTimersByTimeAsync(0); + const result = await capture; + expect(result.producer).toBe('simulator-ax-bridge'); + expect(fallback).not.toHaveBeenCalled(); + expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1); + }, + ); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 079d085d2d..cb4df8560e 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -66,7 +66,7 @@ export function createAppleSnapshotRoute( if (!isEligible(device, input)) return await fallback(input); let target: SimulatorSnapshotTarget; try { - target = await resolveTarget(device, input.options!.appBundleId!, signal); + target = await resolveTargetForObservation(host, resolveTarget, device, input, signal); } catch (error) { signal.throwIfAborted(); emitRouteDiagnostic('target-resolution-failed', device, undefined, error); @@ -143,6 +143,38 @@ export function createAppleSnapshotRoute( }); } +/** + * A discovery still in flight is not a failure while no runner can answer instead. The XCTest + * fallback would first wait for a runner start, and #2198 keeps observation off that wait, so the + * capture stays on the single-flight discovery: each turn waits one discovery slice, and the + * discovery's own deadline or the request signal ends the loop. A runner that is already live + * answers at once, so there the fallback remains the cheaper route (#2331). + */ +async function resolveTargetForObservation( + host: PlatformRuntimeHost, + resolveTarget: SimulatorSnapshotTargetResolver, + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, +): Promise { + const appBundleId = input.options!.appBundleId!; + for (;;) { + try { + return await resolveTarget(device, appBundleId, signal); + } catch (error) { + if (!isDiscoveryPending(error)) throw error; + const execution = { requestId: input.execution?.requestId }; + if (await host.appleApplications.hasLiveRunnerSession(device, execution)) throw error; + } + } +} + +function isDiscoveryPending(error: unknown): boolean { + return ( + error instanceof AppError && error.details?.reason === 'simulator-target-discovery-pending' + ); +} + function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean { return ( device.platform === 'apple' && From 325343d9a713ddab4f01ad93de80ba2075dd1fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 7 Sep 2026 07:48:43 +0200 Subject: [PATCH 21/21] fix(apple): queue a speculative-runner release behind a start that is still in flight A `possible` open's prewarm registers its session only when the start completes, so a `none` open that released in that window found nothing and the runner it meant to release survived as a retained speculative session. The release now takes the runner session lock: it queues behind the in-flight start, sees the registered speculative session, and stops it; a start a command asked for is left alone. Two deferred-start regressions pin both outcomes. --- .../runner-session-speculative.test.ts | 47 +++++++++++++++++++ .../src/runner/runner-session.ts | 20 ++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts index 909bd7645a..8f0691988e 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts @@ -178,3 +178,50 @@ test('releasing a speculative session stops it; a session a command asked for is assert.notEqual(getRunnerSessionSnapshot(demanded.id), null); assert.equal(await releaseSpeculativeIosRunnerSession('no-such-device'), false); }); + +test('a release that arrives while the speculative start is still in flight stops it once it completes', async () => { + // The reviewer's race: a `possible` open's prewarm is blocked before the session is registered, + // a `none` open releases, and the runner must not survive as a retained speculative session. + const device = { ...IOS_SIMULATOR, id: 'runner-session-deferred-release-sim' }; + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async () => { + await gate; + return { release: mockRedirectRelease }; + }); + + const starting = ensureRunnerSession(device, { speculative: true }); + const releasing = releaseSpeculativeIosRunnerSession(device.id); + const settledEarly = await Promise.race([ + releasing.then(() => 'settled'), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 50)), + ]); + assert.equal(settledEarly, 'pending', 'the release waits for the start it cannot yet see'); + assert.equal(getRunnerSessionSnapshot(device.id), null); + + openGate(); + await starting; + assert.equal(await releasing, true); + assert.equal(getRunnerSessionSnapshot(device.id), null, 'the completed start was stopped'); +}); + +test('a release that waits out a demanded start leaves that runner alone', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-deferred-keep-sim' }; + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + mockAcquireXcodebuildSimulatorSetRedirect.mockImplementation(async () => { + await gate; + return { release: mockRedirectRelease }; + }); + + const starting = ensureRunnerSession(device, {}); + const releasing = releaseSpeculativeIosRunnerSession(device.id); + openGate(); + await starting; + assert.equal(await releasing, false); + assert.notEqual(getRunnerSessionSnapshot(device.id), null); +}); diff --git a/packages/platform-apple/src/runner/runner-session.ts b/packages/platform-apple/src/runner/runner-session.ts index e1fc58f338..15864c0798 100644 --- a/packages/platform-apple/src/runner/runner-session.ts +++ b/packages/platform-apple/src/runner/runner-session.ts @@ -536,15 +536,19 @@ export function markRunnerSessionServed(session: RunnerSession, command: RunnerC * the session's working runner and stays under the idle-stop policy. */ export async function releaseSpeculativeIosRunnerSession(deviceId: string): Promise { - const session = runnerSessions.get(deviceId); - if (!session?.speculative) return false; - emitDiagnostic({ - level: 'debug', - phase: 'ios_runner_speculative_released', - data: { deviceId, sessionId: session.sessionId, ready: session.ready }, + // Under the session lock: a prewarm still starting holds it and registers its session only + // when the start completes, so the release queues behind that start instead of missing it. + return await withRunnerSessionLock(deviceId, async () => { + const session = runnerSessions.get(deviceId); + if (!session?.speculative) return false; + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_speculative_released', + data: { deviceId, sessionId: session.sessionId, ready: session.ready }, + }); + await stopIosRunnerSession(deviceId); + return true; }); - await stopIosRunnerSession(deviceId); - return true; } export async function stopIosRunnerSession(deviceId: string): Promise {