diff --git a/.fallowrc.json b/.fallowrc.json index 123e50f85c..22975c7e2f 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -91,6 +91,16 @@ "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", + "releaseSpeculativeIosRunnerSessionFor", + "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/CONTEXT.md b/CONTEXT.md index 85c8960f8e..673d36465f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,24 +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**: +What a Simulator open prepares, or releases unused, of the XCTest runner for remaining 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, diff --git a/packages/command-registry/package.json b/packages/command-registry/package.json index 916fc1856f..04f9add51f 100644 --- a/packages/command-registry/package.json +++ b/packages/command-registry/package.json @@ -14,6 +14,10 @@ "types": "./src/registry.ts", "default": "./src/registry.ts" }, + "./planned-operations": { + "types": "./src/planned-operations.ts", + "default": "./src/planned-operations.ts" + }, "./catalog": { "types": "./src/catalog.ts", "default": "./src/catalog.ts" diff --git a/packages/command-registry/src/__tests__/find-runtime-execution.test.ts b/packages/command-registry/src/__tests__/find-runtime-execution.test.ts index 5e8f601f3a..5adb98c40c 100644 --- a/packages/command-registry/src/__tests__/find-runtime-execution.test.ts +++ b/packages/command-registry/src/__tests__/find-runtime-execution.test.ts @@ -1,14 +1,16 @@ +import { commandDescriptors, selectFindStepUses } from '../registry.ts'; import { expect, test } from 'vitest'; import { findRuntimePlanUses } 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/packages/command-registry/src/__tests__/planned-operations.test.ts b/packages/command-registry/src/__tests__/planned-operations.test.ts new file mode 100644 index 0000000000..4a7e21c25b --- /dev/null +++ b/packages/command-registry/src/__tests__/planned-operations.test.ts @@ -0,0 +1,97 @@ +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, positionals: [], flags: {} })); + +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('captureSnapshotWithCustomActions')); + for (const operation of operations) { + assert.doesNotMatch(operation, /^(tap|fill|type|scroll|perform|hover|focus|longPress)/); + } +}); + +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', positionals: [], flags: { snapshotCustomActions: true } }, + ]); + assert.ok(plain && custom); + assert.ok(!plain.includes('captureSnapshotWithCustomActions')); + assert.ok(custom.includes('captureSnapshotWithCustomActions')); +}); + +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 required touch operations', () => { + const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'click')); + assert.ok(operations); + assert.ok(operations.some((operation) => /^tap/.test(operation))); +}); + +test('commands without device runtime execution contribute nothing', () => { + assert.deepEqual(resolvePlannedRuntimeOperations(steps('devices', 'capabilities')), []); +}); + +test('an unregistered command makes the plan unproven', () => { + 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 = [ + { 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) { + 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/packages/command-registry/src/__tests__/snapshot-runtime-execution.test.ts b/packages/command-registry/src/__tests__/snapshot-runtime-execution.test.ts index 6461897923..96646bd1a4 100644 --- a/packages/command-registry/src/__tests__/snapshot-runtime-execution.test.ts +++ b/packages/command-registry/src/__tests__/snapshot-runtime-execution.test.ts @@ -1,15 +1,17 @@ +import { commandDescriptors, selectSnapshotStepUses } from '../registry.ts'; import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations'; 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'); 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 +29,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, }); }); diff --git a/packages/command-registry/src/planned-operations.ts b/packages/command-registry/src/planned-operations.ts new file mode 100644 index 0000000000..f75f82fafa --- /dev/null +++ b/packages/command-registry/src/planned-operations.ts @@ -0,0 +1,50 @@ +import type { RuntimeUseStep } from '@agent-device/contracts/command-platform-execution'; +import { + isRuntimeOperationName, + type RuntimeOperationName, +} from '@agent-device/contracts/runtime-operation-names'; +import { commandDescriptors } from './registry.ts'; +import type { CommandDescriptor } from './types.ts'; + +const descriptorsByName = new Map( + commandDescriptors.map((descriptor) => [descriptor.name, descriptor]), +); + +/** 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 = RuntimeOperationName; + +/** + * 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 PlannedRuntimeOperation[] | undefined { + const operations = new Set(); + for (const step of steps) { + 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/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index bfc88d98ee..86edf1c15f 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/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, @@ -29,36 +38,39 @@ 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, + findRuntimeIntent, findRuntimePlanUses, focusRuntimeUse, gestureRuntimePlanUses, gestureViewportRuntimeUse, homeRuntimeUse, hoverRuntimeUses, - appEventRuntimeUse, - settingsRuntimeUse, - alertRuntimePlanUses, - appSwitcherRuntimeUse, - tapPointUse, - clipboardRuntimePlanUses, keyboardRuntimePlanUses, longPressRuntimeUses, orientationRuntimeUse, perfRuntimePlanUses, pressRuntimeUses, + resolveSelectorCaptureRuntimePlan, + resolveSnapshotRuntimePlan, screenshotRuntimePlanUses, scrollRuntimePlanUses, - swipeRuntimePlanUses, selectorCaptureRuntimePlanUses, selectorTextCaptureRuntimePlanUses, + settingsRuntimeUse, shutdownTargetUse, snapshotRuntimePlanUses, + swipeRuntimePlanUses, + tapPointUse, tvRemoteRuntimeUse, typeTextRuntimeUse, viewportRuntimeUse, @@ -430,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', @@ -1016,7 +1066,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 +1083,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 +1223,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/packages/contracts/package.json b/packages/contracts/package.json index 7997a6bb4d..6f4f771742 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -328,6 +328,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 d4790a84e3..492e14909d 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -1,8 +1,9 @@ +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'; 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'; @@ -39,6 +40,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 RuntimeOperationName[]; }>; /** Semantic target resolution used before an application open. */ @@ -66,6 +74,17 @@ export type OpenApplicationPreparationInput = Readonly<{ execution: ApplicationLifecycleExecution; }>; +/** + * 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 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'; + /** The normalized public application launch, independent of daemon request shape. */ export type OpenApplicationInput = Readonly<{ target?: string; @@ -92,6 +111,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; @@ -99,6 +120,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<{ @@ -273,6 +296,20 @@ export type AppleApplicationTools = Readonly<{ signal: AbortSignal, ): Promise; stopRunnerSession(deviceId: string): Promise; + /** + * 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( + 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/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..3922e69779 100644 --- a/packages/contracts/src/command-platform-execution.ts +++ b/packages/contracts/src/command-platform-execution.ts @@ -2,6 +2,21 @@ 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 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 = (step: RuntimeUseStep) => readonly RuntimeUseDeclaration[]; + export type CommandPlatformExecution = | Readonly<{ kind: 'none' }> /** @@ -15,6 +30,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 @@ -43,7 +59,9 @@ export function assertCommandPlatformExecution( } if ( declaration['kind'] === 'device-runtime' && - sameKeys(keys, ['kind', 'uses']) && + (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.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/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3b9cff9f81..be957530dc 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -484,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. 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/host-kit/package.json b/packages/host-kit/package.json index 48f74206d6..7b2cb3c2ca 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" + }, "./code-signature": { "types": "./src/code-signature.ts", "default": "./src/code-signature.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/__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/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 5c1f1f8aab..4e9388555b 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -20,6 +20,10 @@ export const runAppleRunnerCommand: AppleRunnerClient['runAppleRunnerCommand'] = client.runAppleRunnerCommand; export const notifyIosRunnerAppRelaunched: AppleRunnerClient['notifyIosRunnerAppRelaunched'] = 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 7c990f78f3..15e2cb50b2 100644 --- a/packages/platform-apple/src/lifecycle.test.ts +++ b/packages/platform-apple/src/lifecycle.test.ts @@ -259,3 +259,272 @@ 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 releaseSpeculativeRunner = vi.fn(async () => { + overrides.events.push('release'); + return true; + }); + const host = { + ...baseHost, + clock: { ...baseHost.clock, sleep: async () => {} }, + localInteractors: { resolve: async () => interactor }, + appleApplications: { + ...baseHost.appleApplications, + prewarmRunnerSession, + notifyRunnerAppRelaunched, + hasLiveRunnerSession, + releaseSpeculativeRunner, + }, + } as unknown as PlatformRuntimeHost; + return { + host, + prewarmRunnerSession, + notifyRunnerAppRelaunched, + hasLiveRunnerSession, + releaseSpeculativeRunner, + }; +} + +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, releaseSpeculativeRunner } = + simulatorHost({ events }); + const lifecycle = bindAppleApplicationLifecycle({ + host, + device: simulator, + signal: new AbortController().signal, + }); + + const outcome = await lifecycle.openApplication({ + ...openInput(), + execution: { plannedOperations: ['captureSnapshot', 'findText', 'captureScreenshot'] }, + }); + + expect(outcome.timing.runnerDemand).toBe('none'); + expect(outcome.timing.runnerPrewarmScheduled).toBeUndefined(); + expect(prewarmRunnerSession).not.toHaveBeenCalled(); + expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled(); + // 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([ + ['an unknown plan', undefined, 'possible'], + ['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) => { + const events: string[] = []; + let releasePrewarm = () => {}; + 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, + signal: new AbortController().signal, + }); + + const opened = lifecycle.openApplication({ + ...openInput(), + relaunch: true, + execution: { plannedOperations: 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(); + // Only a proven observation-only plan releases; a plan that may need the runner keeps it. + expect(releaseSpeculativeRunner).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, {}); + 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(), + execution: { plannedOperations: ['captureSnapshot'] }, + }); + + expect(outcome.timing.runnerDemand).toBeUndefined(); + 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(['release', '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(['release', '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/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index ad03d7df9e..9f3a593d8d 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -17,11 +17,17 @@ import { } from '@agent-device/contracts/application-lifecycle-interaction'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { ensureAppleReady } from './readiness/runtime.ts'; +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'; 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< @@ -35,14 +41,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. */ @@ -68,7 +72,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) => @@ -88,15 +93,15 @@ async function openAppleApplication( host: AppleLifecycleHost, binding: BoundAppleInteractor, input: OpenApplicationInput, + observation: LaunchObservationPort | undefined, ): Promise { const timing: MutableOpenTiming = {}; const localIosSimulator = isIosSimulator(binding.device); const runner = createRunnerPrewarm(host, binding, input, timing); - const shouldPrewarmRunner = - isIosFamily(binding.device) && - input.surface === 'app' && - input.positionals.length > 0 && - Boolean(input.appBundleId); + 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, input, @@ -116,7 +121,7 @@ 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); + await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, policy.awaitPrewarmAfterOpen); await notifyAppleRunnerRelaunch( host, binding, @@ -125,7 +130,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) { @@ -256,6 +261,16 @@ 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. + // An awaited prewarm proved liveness already. + if ( + localIosSimulator && + !runnerTargetPredatesOpen && + !(await host.appleApplications.hasLiveRunnerSession(binding.device, input.execution)) + ) { + return; + } await host.appleApplications.notifyRunnerAppRelaunched( binding.device, input.execution, @@ -263,17 +278,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..df7608e794 --- /dev/null +++ b/packages/platform-apple/src/open-policy.ts @@ -0,0 +1,96 @@ +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 { hasSimulatorBridge, 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 { + // 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 = + isIosFamily(device) && + input.surface === 'app' && + input.positionals.length > 0 && + Boolean(input.appBundleId) && + runnerDemand !== 'none'; + return { + ...(runnerDemand ? { runnerDemand } : {}), + shouldPrewarmRunner, + awaitPrewarmAfterOpen: input.relaunch && !bridge, + }; +} + +/** + * 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 + * 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 && hasSimulatorBridge(binding.device) && 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-demand.test.ts b/packages/platform-apple/src/runner-demand.test.ts new file mode 100644 index 0000000000..d1b7e30156 --- /dev/null +++ b/packages/platform-apple/src/runner-demand.test.ts @@ -0,0 +1,34 @@ +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([ + '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'], +] as const)('a plan containing %s requires the runner', (_name, operation) => { + expect(resolveAppleSimulatorRunnerDemand(['captureSnapshot', operation])).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 new file mode 100644 index 0000000000..c50151a6b5 --- /dev/null +++ b/packages/platform-apple/src/runner-demand.ts @@ -0,0 +1,115 @@ +import type { OpenApplicationRunnerDemand } from '@agent-device/contracts/application-lifecycle-runtime'; +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 + * 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 vocabulary by construction: a new operation refuses to compile until it is classified. + */ +type AppleSimulatorOperationHost = 'runner' | 'simulator'; + +const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< + Record +> = 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 required operations are all simulator-served proves no runner is needed; + * any runner-served operation makes readiness worth preparing now. + */ +export function resolveAppleSimulatorRunnerDemand( + operations: readonly RuntimeOperationName[] | undefined, +): OpenApplicationRunnerDemand { + if (operations === undefined) return 'possible'; + return operations.some((operation) => APPLE_SIMULATOR_OPERATION_HOSTS[operation] === 'runner') + ? 'required' + : 'none'; +} diff --git a/packages/platform-apple/src/runner-operations-facade.ts b/packages/platform-apple/src/runner-operations-facade.ts index 15e5e56130..40a66663a0 100644 --- a/packages/platform-apple/src/runner-operations-facade.ts +++ b/packages/platform-apple/src/runner-operations-facade.ts @@ -2,11 +2,13 @@ export { applyXctestRunnerAppIconFromDerivedPath, detachIosSimulatorRunnerSessionsForShutdown, getRunnerSessionSnapshot, + hasLiveIosRunnerSession, notifyIosRunnerAppRelaunched, prepareIosRunner, prewarmAppleRunnerCache, prewarmIosRunnerSession, readStaleRunnerLease, + releaseSpeculativeIosRunnerSessionFor, resolveRunnerAppBundleId, runAppleRunnerCommand, scheduleIosRunnerIdleStop, 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..6a6545a25f --- /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 bare executor has no session to start and answers at once', 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/__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/__tests__/runner-session-speculative.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts new file mode 100644 index 0000000000..8f0691988e --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-session-speculative.test.ts @@ -0,0 +1,227 @@ +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); +}); + +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/__tests__/runner-session.test.ts b/packages/platform-apple/src/runner/__tests__/runner-session.test.ts index 90e38562ec..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,10 +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, - }); + 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/packages/platform-apple/src/runner/client.ts b/packages/platform-apple/src/runner/client.ts index fd4101f304..10527d952c 100644 --- a/packages/platform-apple/src/runner/client.ts +++ b/packages/platform-apple/src/runner/client.ts @@ -1,5 +1,7 @@ import { bindAppleRunnerHost, type AppleRunnerHost } from './host.ts'; import { + hasLiveIosRunnerSession, + releaseSpeculativeIosRunnerSessionFor, notifyIosRunnerAppRelaunched, prepareIosRunner, prewarmAppleRunnerCache, @@ -33,6 +35,8 @@ import { hasCachedAppleRunnerArtifact, resolveRunnerAppBundleId } from './runner export type AppleRunnerClient = { runAppleRunnerCommand: typeof runAppleRunnerCommand; notifyIosRunnerAppRelaunched: typeof notifyIosRunnerAppRelaunched; + hasLiveIosRunnerSession: typeof hasLiveIosRunnerSession; + releaseSpeculativeIosRunnerSessionFor: typeof releaseSpeculativeIosRunnerSessionFor; prewarmAppleRunnerCache: typeof prewarmAppleRunnerCache; prewarmIosRunnerSession: typeof prewarmIosRunnerSession; prepareIosRunner: typeof prepareIosRunner; @@ -62,6 +66,8 @@ export function createAppleRunnerClient(host: AppleRunnerHost): AppleRunnerClien return { 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 4bf490d9dc..eb5eb6d4dd 100644 --- a/packages/platform-apple/src/runner/runner-client.ts +++ b/packages/platform-apple/src/runner/runner-client.ts @@ -2,8 +2,10 @@ import { retryWithPolicy, emitDiagnostic } from './host.ts'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { ensureRunnerSession, + getRunnerSessionSnapshot, stopIosRunnerSession, validateRunnerDevice, + releaseSpeculativeIosRunnerSession, } from './runner-session.ts'; import { assertRunnerRequestActive, @@ -176,16 +178,46 @@ function resolveAppleRunnerRuntime( }); } +/** + * 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, + options: { requestId?: string } = {}, +): boolean { + if (!isIosFamily(device)) return false; + 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 e0cb556ac9..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 & { @@ -74,6 +76,16 @@ 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. Every provider + * 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 = { @@ -91,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 }; } @@ -139,7 +154,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/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 8dcd55c893..15864c0798 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, { @@ -421,12 +422,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, }; } @@ -520,6 +523,34 @@ 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 { + // 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; + }); +} + export async function stopIosRunnerSession(deviceId: string): Promise { cancelIosRunnerIdleStop(deviceId); await withRunnerSessionLock(deviceId, async () => { 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..e44a026b70 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -14,6 +14,7 @@ import type { PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; 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. */ @@ -71,7 +72,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( @@ -80,23 +85,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(); + 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); }, }); } @@ -108,26 +104,54 @@ 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(); + 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 }>; + execution?: Readonly<{ requestId?: 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 runnerCanAnswerNow(host, request.device, input.execution))) return undefined; + return { appBundleId, signal }; +} + +/** + * 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 (!hasSimulatorBridge(device)) return true; + return await host.appleApplications.hasLiveRunnerSession(device, execution ?? {}); +} diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index efb332a54b..8b430a1076 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 () => {}, @@ -57,6 +42,8 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { prewarmRunnerSession: async () => {}, notifyRunnerAppRelaunched: async () => {}, stopRunnerSession: async () => {}, + hasLiveRunnerSession: async () => false, + releaseSpeculativeRunner: async () => false, scheduleRunnerIdleStop: () => {}, prepareRunner: async () => ({ runner: {}, connectMs: 0, healthCheckMs: 0 }), applyRuntimeHints: async () => {}, 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..9854396e7a --- /dev/null +++ b/packages/platform-apple/src/snapshot-observability.test.ts @@ -0,0 +1,154 @@ +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.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(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..c782a9fca7 --- /dev/null +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -0,0 +1,77 @@ +import { + createIosSnapshotRequest, + deriveIosCaptureHint, +} from '@agent-device/capture-kit/ios-snapshot-planning'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import 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], +]); + +/** 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; + resolveTarget: SimulatorSnapshotTargetResolver; + clock: PlatformRuntimeHost['clock']; + }>, +): LaunchObservationPort { + const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true })); + return Object.freeze({ + awaitObservable: async (device, appBundleId, signal) => { + if (!hasSimulatorBridge(device)) 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 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 c4ae56cc08..cb4df8560e 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -23,6 +23,10 @@ import { type SimulatorSnapshotSource, type SnapshotSourceFailure, } from './snapshot-source-facade.ts'; +import { + createLaunchObservationProbe, + type LaunchObservationPort, +} from './snapshot-observability.ts'; import { createSimulatorSnapshotTargetResolver, type SimulatorSnapshotTarget, @@ -31,15 +35,16 @@ import { type SnapshotFallback = (input: CaptureSnapshotInput) => Promise; -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, @@ -52,14 +57,16 @@ 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); 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); @@ -136,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' && diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 2e44a0745d..29aeba14ec 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -126,7 +126,16 @@ export const NEW_ENTRY_CEILINGS: Readonly> = Objec */ export const APPROVED_OVER_CEILING: Readonly< Record -> = Object.freeze({}); +> = Object.freeze({ + 'packages/command-registry/src/planned-operations.ts': { + issue: '#2198', + reason: + 'Flattens the required runtime operations of the remaining batch steps from the registry, ' + + 'so its closure is the registry entry itself plus the operation-name vocabulary; a lighter ' + + 'closure would mean a second copy of the descriptors.', + owner: 'thymikee', + }, +}); /** The category is a function of the path, never a hand-written column. */ export function entryCategoryOf(entryFile: string): EntryCategory { 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 a4fa2c49fb..399c0e334b 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..454fe2f46d 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 { @@ -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 && deepLinkConfirmationShown(context, opened) + ? 'label="Open"' + : (fixture.interactionTarget ?? `text=${JSON.stringify(fixture.anchorText)}`); + return pressFixtureTarget(context, selector); +} + export async function openFixtureAsync( context: CliContext, fixture: ScreenFixture, @@ -74,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 { @@ -132,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 []; 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..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', @@ -84,7 +86,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..e807e819fe 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 = @@ -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 = { @@ -52,7 +57,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; diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 615a36938f..988c6d6d21 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -86,6 +86,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/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 1b41a644d5..7c6ba45bbb 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -455,6 +455,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/code-signature', '@agent-device/host-kit/code-signature-cache', '@agent-device/host-kit/command', diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index 74614d3bb4..881e7aa669 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -118,3 +118,40 @@ 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 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: [ + { command: 'snapshot', positionals: [], flags: {}, input: { interactiveOnly: true } }, + { command: 'click', positionals: [], flags: {} }, + ], + step: 1, + 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 c66fe805c8..faa7ff4c25 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -34,7 +34,23 @@ 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; + /** The steps still ahead, in the shape their handlers will read. */ + remainingSteps: readonly Readonly<{ + command: string; + positionals: readonly string[]; + flags: Readonly>; + input?: Readonly>; + }>[]; +}>; + +export type BatchInvoke = (req: BatchRequest, context: BatchStepContext) => Promise; export type NormalizedBatchStep = { command: string; @@ -88,14 +104,16 @@ 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, + remainingSteps: steps.slice(index + 1).map((remaining) => ({ + command: remaining.command, + positionals: remaining.positionals, + flags: remaining.flags, + ...(remaining.input === undefined ? {} : { input: remaining.input }), + })), + }); if (!stepResponse.ok) { return { ok: false, @@ -249,8 +267,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 +276,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/daemon/__tests__/execution-plan.test.ts b/src/daemon/__tests__/execution-plan.test.ts new file mode 100644 index 0000000000..75f41c0544 --- /dev/null +++ b/src/daemon/__tests__/execution-plan.test.ts @@ -0,0 +1,49 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { resolvePlannedOperations } from '../execution-plan.ts'; +import { runBatchCommands } from '../handlers/session-batch.ts'; +import type { DaemonRequest } from '../daemon-request.ts'; + +test('an open that ends its batch has an unknown future', () => { + assert.equal(resolvePlannedOperations(undefined), undefined); + assert.equal(resolvePlannedOperations({ remainingSteps: [] }), undefined); +}); + +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(operations); + assert.ok(operations.includes('captureSnapshot')); + assert.ok(!operations.some((operation) => /^tap/.test(operation))); +}); + +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: { depth: 1 } }] }, + }; + const response = await runBatchCommands(req, 'session', async (stepRequest) => { + seen.push({ + command: stepRequest.command, + remaining: stepRequest.internal?.executionPlan?.remainingSteps, + }); + return { ok: true, data: {} }; + }); + + assert.equal(response.ok, true); + assert.deepEqual(seen, [ + { + command: 'open', + remaining: [{ command: 'snapshot', positionals: [], flags: { depth: 1 } }], + }, + { command: 'snapshot', remaining: [] }, + ]); +}); diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index 82095fc325..13209ba53c 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/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index 2893431ce7..133b1c46ec 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 3540b121bb..1e1f77fc3d 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 7bcd17844a..ca2fdbcbf8 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); diff --git a/src/daemon/application-lifecycle-execution.ts b/src/daemon/application-lifecycle-execution.ts index ee1ba79e47..17d2d4855e 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 './daemon-request.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/daemon-request.ts b/src/daemon/daemon-request.ts index a1cebef2af..61b52354d1 100644 --- a/src/daemon/daemon-request.ts +++ b/src/daemon/daemon-request.ts @@ -1,3 +1,4 @@ +import type { ExecutionPlan } from './execution-plan.ts'; import type { GestureExecutionProfile } from '@agent-device/contracts/gesture-plan-types'; import type { PreresolvedInteractionTarget } from '@agent-device/contracts/interaction'; import type { DeviceLease } from '@agent-device/contracts/device'; @@ -30,6 +31,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/daemon/execution-plan.ts b/src/daemon/execution-plan.ts new file mode 100644 index 0000000000..9a2badcff7 --- /dev/null +++ b/src/daemon/execution-plan.ts @@ -0,0 +1,25 @@ +import { + resolvePlannedRuntimeOperations, + type PlannedRuntimeOperation, + type PlannedStep, +} from '@agent-device/command-registry/planned-operations'; + +/** + * 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<{ remainingSteps: readonly PlannedStep[] }>; + +/** + * 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 resolvePlannedOperations( + plan: ExecutionPlan | undefined, +): readonly PlannedRuntimeOperation[] | undefined { + if (plan === undefined || plan.remainingSteps.length === 0) return undefined; + return resolvePlannedRuntimeOperations(plan.remainingSteps); +} diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 3c446dbef4..c416d5e2a3 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/daemon/handlers/session-batch.ts b/src/daemon/handlers/session-batch.ts index 4b326796a8..79c2f2f6e8 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 '../daemon-request.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: { remainingSteps: context.remainingSteps }, + }, + }); + }); } diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 4f196ee296..679f70e811 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -24,7 +24,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'; @@ -107,7 +110,7 @@ export async function handleFindCommands(params: FindRouteInput): Promise { + 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( diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 9ebfc7aecb..321e287871 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -76,6 +76,16 @@ export function createAppleApplicationTools(): AppleApplicationTools { const { stopIosRunnerSession } = await loadRunnerOperations(); await stopIosRunnerSession(deviceId); }, + hasLiveRunnerSession: async (device, execution) => { + 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), 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 313d6911a1..11f8243de0 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 e762cc11e6..c966ce590f 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, }; }