From e2a1b504b7b083fd951819fdc33fe5acedb1ac00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 13:00:05 +0200 Subject: [PATCH 01/42] feat(errors): disclose whether a failed interaction reached the device details.dispatched carries no, yes, or unknown on interaction failures, anchored on the ADR 0014 side-effect seam: a failure before the seam is no, one after it is unknown unless a producer proves yes. Producers: the iOS runner reply and status-probe verdict tables, Android adb input and helper gestures, the Android post-tap in-app guard. The rows live in contracts/fixtures/dispatch-disclosure.json and every row is driven through its real producer by an owning test, gated for coverage. --- contracts/fixtures/dispatch-disclosure.json | 229 ++++++++++++++++++ .../0011-interaction-guarantee-contract.md | 10 + packages/contracts/package.json | 4 + .../src/dispatch-disclosure.fixtures.ts | 83 +++++++ .../contracts/src/dispatch-disclosure.test.ts | 70 ++++++ packages/kernel/src/errors.test.ts | 18 ++ packages/kernel/src/errors.ts | 46 +++- .../src/__tests__/dispatch-disclosure.test.ts | 133 ++++++++++ .../__tests__/touch-helper-session.test.ts | 65 +++++ .../platform-android/src/input-actions.ts | 22 +- .../src/snapshot-helper-session.ts | 30 ++- packages/platform-android/src/text-input.ts | 30 ++- packages/platform-android/src/touch-helper.ts | 51 ++-- .../runner-dispatch-disclosure.test.ts | 151 ++++++++++++ .../src/runner/runner-command-recovery.ts | 53 ++-- .../src/runner/runner-contract.ts | 43 +++- .../src/runner/runner-error-classification.ts | 8 +- .../src/snapshot-source/lifecycle.ts | 2 +- .../src/snapshot-source/transport.ts | 10 +- .../layering/contracts-exports.snapshot.json | 1 + .../interaction-dispatch-disclosure.test.ts | 123 ++++++++++ .../internal/interaction-android-escape.ts | 2 +- .../interaction-dispatch-disclosure.ts | 38 +++ .../interaction/internal/interaction.ts | 6 +- .../runtime-selector.contract.test.ts | 17 +- .../runtime-selector.coverage.ts | 4 +- test/wire-compat/ledger.json | 14 +- test/wire-compat/surface.ts | 2 + website/docs/docs/commands.md | 1 + 29 files changed, 1194 insertions(+), 72 deletions(-) create mode 100644 contracts/fixtures/dispatch-disclosure.json create mode 100644 packages/contracts/src/dispatch-disclosure.fixtures.ts create mode 100644 packages/contracts/src/dispatch-disclosure.test.ts create mode 100644 packages/platform-android/src/__tests__/dispatch-disclosure.test.ts create mode 100644 packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts create mode 100644 src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts create mode 100644 src/daemon/interaction/internal/interaction-dispatch-disclosure.ts diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json new file mode 100644 index 0000000000..59b235d91f --- /dev/null +++ b/contracts/fixtures/dispatch-disclosure.json @@ -0,0 +1,229 @@ +[ + { + "id": "daemon.refusal-before-seam", + "phase": "before-seam", + "producer": "daemon", + "trigger": "interaction failure raised before expireRefFrame advanced the session runtime revision", + "dispatched": "no" + }, + { + "id": "daemon.unclassified-after-seam", + "phase": "after-seam", + "producer": "daemon", + "trigger": "interaction failure raised after expireRefFrame with no producer verdict", + "dispatched": "unknown" + }, + { + "id": "post-action-guard.android-press-left-app", + "phase": "after-seam", + "producer": "post-action-guard", + "trigger": "assertAndroidPressStayedInApp observed an escape surface in the foreground", + "dispatched": "yes" + }, + { + "id": "ios-runner.reply.RUNNER_BUSY", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code RUNNER_BUSY: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.RUNNER_WEDGED", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code RUNNER_WEDGED: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.APP_NOT_RUNNING", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code APP_NOT_RUNNING: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.SCROLL_KEYBOARD_OCCLUDES_SURFACE", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code SCROLL_KEYBOARD_OCCLUDES_SURFACE: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.ALERT_NOT_FOUND", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code ALERT_NOT_FOUND: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.APP_SCREEN_WINDOW_UNRESOLVED", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code APP_SCREEN_WINDOW_UNRESOLVED: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.APP_SCREEN_UNRESOLVED", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code APP_SCREEN_UNRESOLVED: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.APP_SCREEN_CAPTURE_UNRENDERABLE", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code APP_SCREEN_CAPTURE_UNRENDERABLE: a refusal that ran nothing", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.MAIN_THREAD_TIMEOUT", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code MAIN_THREAD_TIMEOUT: abandoned work may still land", + "dispatched": "unknown" + }, + { + "id": "ios-runner.reply.executed-failure", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with any other code, e.g. XCTEST_RECORDED_FAILURE", + "dispatched": "yes" + }, + { + "id": "ios-runner.status.failed", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; status probe reports lifecycleState failed", + "dispatched": "yes" + }, + { + "id": "ios-runner.status.failed-RUNNER_BUSY", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; status probe reports lifecycleState failed with journal code RUNNER_BUSY", + "dispatched": "no" + }, + { + "id": "ios-runner.status.completed-without-retained-reply", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; status probe reports lifecycleState completed without a readable retained reply", + "dispatched": "yes" + }, + { + "id": "ios-runner.status.accepted", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; status probe reports lifecycleState accepted", + "dispatched": "unknown" + }, + { + "id": "ios-runner.status.started", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; status probe reports lifecycleState started", + "dispatched": "unknown" + }, + { + "id": "ios-runner.status.notAccepted", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; status probe reports lifecycleState notAccepted (the journal may not have survived a restart)", + "dispatched": "unknown" + }, + { + "id": "ios-runner.status.probe-failed", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; the status probe itself failed", + "dispatched": "unknown" + }, + { + "id": "ios-runner.status.unavailable", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "transport lost; the command carries no commandId to probe", + "dispatched": "unknown" + }, + { + "id": "android-adb.input-tap.tool-missing", + "phase": "after-seam", + "producer": "android-adb", + "trigger": "adb input tap could not start: TOOL_MISSING", + "dispatched": "no" + }, + { + "id": "android-adb.input-tap.failed", + "phase": "after-seam", + "producer": "android-adb", + "trigger": "adb input tap started and failed", + "dispatched": "unknown" + }, + { + "id": "android-adb.input-text.failed-after-chunk", + "phase": "after-seam", + "producer": "android-adb", + "trigger": "adb input text failed after at least one chunk was typed (details.dispatchedSteps)", + "dispatched": "unknown" + }, + { + "id": "android-helper.gesture.reported-failure", + "phase": "after-seam", + "producer": "android-helper", + "trigger": "one-shot helper gesture returned a parsed final result with ok=false", + "dispatched": "yes" + }, + { + "id": "android-helper.gesture.failed-after-result", + "phase": "after-seam", + "producer": "android-helper", + "trigger": "one-shot helper gesture returned a parsed final result and exited non-zero", + "dispatched": "yes" + }, + { + "id": "android-helper.gesture.no-parseable-output", + "phase": "after-seam", + "producer": "android-helper", + "trigger": "one-shot helper gesture failed before returning parseable output", + "dispatched": "unknown" + }, + { + "id": "android-helper.gesture-session.reported-failure", + "phase": "after-seam", + "producer": "android-helper", + "trigger": "persistent helper session answered the gesture with ok=false", + "dispatched": "yes" + }, + { + "id": "android-helper.gesture-session.transport-failure", + "phase": "after-seam", + "producer": "android-helper", + "trigger": "persistent helper session transport failed during the gesture", + "dispatched": "unknown" + }, + { + "id": "webdriver.connect-refused", + "phase": "after-seam", + "producer": "webdriver", + "trigger": "connect-refused", + "dispatched": "no", + "implementedBy": "fix/webdriver-no-mutation-resend" + }, + { + "id": "webdriver.timeout-after-send", + "phase": "after-seam", + "producer": "webdriver", + "trigger": "timeout-after-send", + "dispatched": "unknown", + "implementedBy": "fix/webdriver-no-mutation-resend" + }, + { + "id": "webdriver.http-5xx-after-send", + "phase": "after-seam", + "producer": "webdriver", + "trigger": "http-5xx-after-send", + "dispatched": "unknown", + "implementedBy": "fix/webdriver-no-mutation-resend" + } +] diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 2a53d007b7..0191c58ae0 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -150,6 +150,16 @@ Parity is enforced by **golden fixture tables**: JSON files under Drift between TS and Swift then turns CI red on whichever side changed, without needing a simulator. +`contracts/fixtures/dispatch-disclosure.json` is the table for +`AppErrorDetails.dispatched` on interaction failures: one row per producer +event, each with the value it must leave. The ADR 0014 side-effect seam is the +anchor: a failure raised before this request advanced the session runtime +revision is `no`, one raised after it is `unknown` unless a producer proved +`yes` (or `no`) first; the daemon applies that rule once, around interaction +dispatch, and never overwrites a producer's value. Each row names its driver +file by id prefix, that file drives the real producer, and a row marked +`implementedBy` waits for the branch that ships it. + For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of bug where `fill @ref` rebuilt its response by hand and dropped `evidence`). A diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9582f1ada8..328cdce13a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -172,6 +172,10 @@ "types": "./src/device-shutdown-runtime.ts", "default": "./src/device-shutdown-runtime.ts" }, + "./dispatch-disclosure-fixtures": { + "types": "./src/dispatch-disclosure.fixtures.ts", + "default": "./src/dispatch-disclosure.fixtures.ts" + }, "./divergence": { "types": "./src/facades/divergence.ts", "default": "./src/facades/divergence.ts" diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts new file mode 100644 index 0000000000..e0db69cb94 --- /dev/null +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { DispatchDisclosure } from '@agent-device/kernel/errors'; + +export const DISPATCH_DISCLOSURE_PHASES = ['before-seam', 'after-seam'] as const; + +export const DISPATCH_DISCLOSURE_PRODUCERS = [ + 'daemon', + 'ios-runner', + 'android-adb', + 'android-helper', + 'webdriver', + 'post-action-guard', +] as const; + +export type DispatchDisclosureProducer = (typeof DISPATCH_DISCLOSURE_PRODUCERS)[number]; + +export type DispatchDisclosureRow = { + id: string; + phase: (typeof DISPATCH_DISCLOSURE_PHASES)[number]; + producer: DispatchDisclosureProducer; + trigger: string; + dispatched: DispatchDisclosure; + /** A branch that ships this row's producer; the row is not owned here until it merges. */ + implementedBy?: string; +}; + +const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); + +export const DISPATCH_DISCLOSURE_TABLE_PATH = path.join( + REPO_ROOT, + 'contracts/fixtures/dispatch-disclosure.json', +); + +/** + * The test file that drives each row through its real producer, keyed by row-id prefix; the + * longest matching prefix owns the row. A row naming `implementedBy` has no owner until that branch + * lands. + */ +export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> = { + 'daemon.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', + 'post-action-guard.': + 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', + 'ios-runner.': 'packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts', + 'android-adb.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', + 'android-helper.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', + 'android-helper.gesture-session.': + 'packages/platform-android/src/__tests__/touch-helper-session.test.ts', +}; + +export function dispatchDisclosureDriverOwner(rowId: string): string | undefined { + const prefix = Object.keys(DISPATCH_DISCLOSURE_DRIVER_OWNERS) + .filter((candidate) => rowId.startsWith(candidate)) + .sort((left, right) => right.length - left.length)[0]; + return prefix === undefined ? undefined : DISPATCH_DISCLOSURE_DRIVER_OWNERS[prefix]; +} + +/** The table's JSON text, read by the test that consumes it: contracts hold no host file access. */ +export function parseDispatchDisclosureTable(tableText: string): DispatchDisclosureRow[] { + return JSON.parse(tableText) as DispatchDisclosureRow[]; +} + +/** The rows the given driver file owns, less those another branch ships. */ +export function dispatchDisclosureRowsOwnedBy( + driverFileUrl: string, + tableText: string, +): DispatchDisclosureRow[] { + const driverFile = path.relative(REPO_ROOT, fileURLToPath(driverFileUrl)); + return parseDispatchDisclosureTable(tableText).filter( + (row) => + row.implementedBy === undefined && dispatchDisclosureDriverOwner(row.id) === driverFile, + ); +} + +/** A driver file drives exactly the rows it owns: none missing, none naming an absent row. */ +export function assertDispatchDisclosureDriversMatchRows( + owned: readonly DispatchDisclosureRow[], + driverIds: Iterable, +): void { + assert.ok(owned.length > 0, 'a dispatch-disclosure driver file must own at least one row'); + assert.deepEqual([...driverIds].sort(), owned.map((row) => row.id).sort()); +} diff --git a/packages/contracts/src/dispatch-disclosure.test.ts b/packages/contracts/src/dispatch-disclosure.test.ts new file mode 100644 index 0000000000..ab6f8de956 --- /dev/null +++ b/packages/contracts/src/dispatch-disclosure.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; +import { + AppError, + discloseDispatch, + discloseUnclassifiedDispatch, +} from '@agent-device/kernel/errors'; +import { + DISPATCH_DISCLOSURE_DRIVER_OWNERS, + DISPATCH_DISCLOSURE_PHASES, + DISPATCH_DISCLOSURE_PRODUCERS, + dispatchDisclosureDriverOwner, + DISPATCH_DISCLOSURE_TABLE_PATH, + parseDispatchDisclosureTable, +} from './dispatch-disclosure.fixtures.ts'; + +const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); +const DISPATCH_VALUES: readonly string[] = ['no', 'yes', 'unknown']; + +test('a producer verdict overwrites; the seam verdict fills only an unclassified failure', () => { + const error = new AppError('COMMAND_FAILED', 'tap failed', { hint: 'retry' }); + assert.equal(discloseDispatch(error, 'unknown', { dispatchedSteps: 2 }), error); + assert.deepEqual(error.details, { hint: 'retry', dispatchedSteps: 2, dispatched: 'unknown' }); + discloseUnclassifiedDispatch(error, 'no'); + assert.equal(error.details?.dispatched, 'unknown'); + const unclassified = new AppError('COMMAND_FAILED', 'tap failed'); + assert.equal(discloseUnclassifiedDispatch(unclassified, 'no').details?.dispatched, 'no'); +}); + +test('dispatch-disclosure rows are unique and use the declared vocabulary', () => { + const rows = parseDispatchDisclosureTable( + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), + ); + assert.ok(rows.length > 0); + assert.equal(new Set(rows.map((row) => row.id)).size, rows.length, 'row ids must be unique'); + for (const row of rows) { + assert.ok((DISPATCH_DISCLOSURE_PHASES as readonly string[]).includes(row.phase), row.id); + assert.ok((DISPATCH_DISCLOSURE_PRODUCERS as readonly string[]).includes(row.producer), row.id); + assert.ok(DISPATCH_VALUES.includes(row.dispatched), row.id); + assert.ok(row.trigger.trim().length > 0, row.id); + } +}); + +test('every row without implementedBy has a driver file, and every owner prefix owns a row', () => { + const rows = parseDispatchDisclosureTable( + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), + ); + const unowned = rows + .filter((row) => row.implementedBy === undefined) + .filter((row) => dispatchDisclosureDriverOwner(row.id) === undefined) + .map((row) => row.id); + assert.deepEqual(unowned, []); + const idlePrefixes = Object.keys(DISPATCH_DISCLOSURE_DRIVER_OWNERS).filter( + (prefix) => !rows.some((row) => row.id.startsWith(prefix)), + ); + assert.deepEqual(idlePrefixes, []); + for (const driver of new Set(Object.values(DISPATCH_DISCLOSURE_DRIVER_OWNERS))) { + const driverPath = path.join(REPO_ROOT, driver); + assert.ok(fs.existsSync(driverPath), `missing driver file ${driver}`); + const source = fs.readFileSync(driverPath, 'utf8'); + assert.ok( + /dispatchDisclosureRowsOwnedBy\(\s*import\.meta\.url,/.test(source) && + source.includes('assertDispatchDisclosureDriversMatchRows('), + `${driver} must drive the rows it owns and assert its drivers match them`, + ); + } +}); diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index 329bf46504..55bce9c75d 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + AppError, + normalizeError, + throwDaemonError, readElementMatchCandidateRefs, readErrorCandidateViews, summarizeCommandAttemptFailures, @@ -71,3 +74,18 @@ test('summarizeCommandAttemptFailures keeps every attempt in the order it ran', ['first:1', 'second:9'], ); }); + +test('normalizeError keeps a producer dispatch disclosure in details and never invents one', () => { + for (const dispatched of ['no', 'yes', 'unknown'] as const) { + const normalized = normalizeError(new AppError('COMMAND_FAILED', 'tap failed', { dispatched })); + assert.equal(normalized.details?.dispatched, dispatched); + assert.throws( + () => throwDaemonError(normalized), + (error: unknown) => error instanceof AppError && error.details?.dispatched === dispatched, + ); + } + const unclassified = normalizeError(new AppError('COMMAND_FAILED', 'tap failed', { x: 1 })); + assert.equal(unclassified.details?.dispatched, undefined); + assert.equal('dispatched' in (unclassified.details ?? {}), false); + assert.equal(normalizeError(new AppError('DEVICE_IN_USE', 'busy')).details, undefined); +}); diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index b8fe2b1aa9..592c53ea80 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -56,6 +56,17 @@ export type DiagnosticsRecordRef = { requestId: string; }; +/** + * Whether the operation a failed request asked for reached the device: `no` when it provably did + * not, `yes` when a producer proved it executed, `unknown` when neither can be proven. A failure + * without the field was classified by no producer. The producer rows live in + * `contracts/fixtures/dispatch-disclosure.json`. + */ +export type DispatchDisclosure = 'no' | 'yes' | 'unknown'; + +/** The error details bag as it crosses the wire: free-form, with the typed keys a reader may rely on. */ +export type ErrorWireDetails = Record & { dispatched?: DispatchDisclosure }; + export type ErrorCause = { message: string; code?: string; @@ -73,6 +84,8 @@ export type ErrorCause = { * rather than by hand. * - `retriable` — typed retry signal hoisted to the wire error shape. * - `reason` — machine-dispatchable sub-classification within a code. + * - `dispatched` — {@link DispatchDisclosure} set by the producer that proved it; kept in details on + * the wire and never defaulted. */ export type AppErrorDetails = Record & { hint?: string; @@ -88,6 +101,7 @@ export type AppErrorDetails = Record & { // null mirrors the raw child_process exit event: killed by signal, no code. exitCode?: number | null; reason?: string; + dispatched?: DispatchDisclosure; }; export type NormalizedError = { @@ -119,7 +133,7 @@ export type NormalizedError = { */ retriable?: boolean; supportedOn?: string; - details?: Record; + details?: ErrorWireDetails; }; export type ElementMatchCandidateDetails = { @@ -199,7 +213,7 @@ export type DaemonError = { * being handed a path on a filesystem it cannot read. */ diagnosticsRecord?: DiagnosticsRecordRef; - details?: Record; + details?: ErrorWireDetails; /** Additive retry and platform-support signals; absent when not derivable. */ retriable?: boolean; supportedOn?: string; @@ -531,3 +545,31 @@ export function defaultHintForCode(code: string): string | undefined { return 'Retry with --debug and inspect diagnostics log for details.'; } } + +export type DispatchDisclosureEvidence = { + /** Steps of a multi-step operation that reached the device before it failed. */ + dispatchedSteps?: number; +}; + +/** + * Records, on the failure it proved, what a producer knows about whether the requested operation + * reached the device. A producer owns its verdict, so this overwrites; a later layer that only + * infers the verdict from its own side-effect seam uses {@link discloseUnclassifiedDispatch}. + */ +export function discloseDispatch( + error: Failure, + dispatched: DispatchDisclosure, + evidence: DispatchDisclosureEvidence = {}, +): Failure { + error.details = { ...error.details, ...evidence, dispatched }; + return error; +} + +/** The side-effect seam's verdict, recorded only when no producer classified the failure. */ +export function discloseUnclassifiedDispatch( + error: Failure, + dispatched: DispatchDisclosure, +): Failure { + if (error.details?.dispatched !== undefined) return error; + return discloseDispatch(error, dispatched); +} diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts new file mode 100644 index 0000000000..1cb0e2d43c --- /dev/null +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import './test-utils/android-host-test-setup.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; +import { withAndroidAdbProvider, type AndroidAdbExecutor } from '../adb-executor.ts'; +import { pressAndroid } from '../input-actions.ts'; +import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; +import { typeAndroid } from '../text-input.ts'; +import { executeAndroidTouchHelperPlan } from '../touch-helper.ts'; +import { lowerAndroidTouchPlan } from '../touch-plan-lowering.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; +import { withFakeAdb } from './test-utils/fake-adb.ts'; +import { + ANDROID_TOUCH_HELPER_MANIFEST as manifest, + androidTouchHelperResultRecord as resultRecord, + currentVersionAdb, + flingPlan, + makeIsolatedDevice, +} from './touch-helper.fixtures.ts'; + +// contracts/fixtures/dispatch-disclosure.json, adb-input and one-shot helper rows: each drives the +// production entry point over a scripted adb and asserts the `details.dispatched` it fails with. + +vi.mock('../helper-package-install.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveAndroidHelperArtifact: vi.fn(async () => ({ + apkPath: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT.apkPath, + manifest: { ...manifest, sha256: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT.manifest.sha256 }, + })), + }; +}); + +beforeEach(async () => { + delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + await resetAndroidSnapshotHelperSessions(); +}); + +afterEach(async () => { + delete process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; + await resetAndroidSnapshotHelperSessions(); +}); + +function isShellInput(args: readonly string[], subcommand: 'tap' | 'text'): boolean { + return args[0] === 'shell' && args[1] === 'input' && args[2] === subcommand; +} + +async function tapWithAdbAnswer(answer: Error | { exitCode: number; stderr: string }) { + await withFakeAdb( + (args) => (isShellInput(args, 'tap') ? answer : undefined), + async ({ device }) => await pressAndroid(device, 10, 20), + ); +} + +async function typeFailingOnSecondChunk(): Promise { + let textChunks = 0; + await withFakeAdb( + (args) => { + if (!isShellInput(args, 'text')) return undefined; + textChunks += 1; + return textChunks === 2 ? { exitCode: 1, stderr: 'error: device offline' } : undefined; + }, + async ({ device }) => await typeAndroid(device, 'filed the expense'), + ); +} + +async function oneShotGesture(instrument: AndroidAdbExecutor): Promise { + const device = makeIsolatedDevice(); + await withAndroidAdbProvider( + { exec: currentVersionAdb(instrument) }, + { serial: device.id }, + async () => await executeAndroidTouchHelperPlan(device, lowerAndroidTouchPlan(flingPlan())), + ); +} + +const HELPER_REPORTED_FAILURE = resultRecord({ + ok: 'false', + errorType: 'java.lang.IllegalStateException', + message: 'injectInputEvent returned false', +}); +const HELPER_RESULT = resultRecord({ ok: 'true', kind: 'swipe', injectedEvents: '4' }); + +const DRIVERS: Record Promise; dispatchedSteps?: number }> = { + 'android-adb.input-tap.tool-missing': { + drive: () => tapWithAdbAnswer(new AppError('TOOL_MISSING', 'adb not found in PATH')), + }, + 'android-adb.input-tap.failed': { + drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'error: device offline' }), + }, + 'android-adb.input-text.failed-after-chunk': { + drive: typeFailingOnSecondChunk, + dispatchedSteps: 1, + }, + 'android-helper.gesture.reported-failure': { + drive: () => + oneShotGesture(async () => ({ exitCode: 0, stdout: HELPER_REPORTED_FAILURE, stderr: '' })), + }, + 'android-helper.gesture.failed-after-result': { + drive: () => oneShotGesture(async () => ({ exitCode: 1, stdout: HELPER_RESULT, stderr: '' })), + }, + 'android-helper.gesture.no-parseable-output': { + drive: () => oneShotGesture(async () => ({ exitCode: 1, stdout: '', stderr: 'boom' })), + }, +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every adb-input and one-shot helper dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const driver = DRIVERS[row.id]; + assert.ok(driver, `no driver for ${row.id}`); + await assert.rejects(driver.drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + assert.equal(error.details?.dispatchedSteps, driver.dispatchedSteps); + return true; + }); + }); +} diff --git a/packages/platform-android/src/__tests__/touch-helper-session.test.ts b/packages/platform-android/src/__tests__/touch-helper-session.test.ts index ef99f04db5..7e7945c979 100644 --- a/packages/platform-android/src/__tests__/touch-helper-session.test.ts +++ b/packages/platform-android/src/__tests__/touch-helper-session.test.ts @@ -6,6 +6,7 @@ // gesture/viewport calls against it to pin the session transport contract. import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { EventEmitter } from 'node:events'; import net from 'node:net'; import { PassThrough } from 'node:stream'; @@ -13,6 +14,11 @@ import { afterEach, beforeEach, test, vi } from 'vitest'; import './test-utils/android-host-test-setup.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { withAndroidAdbProvider, type AndroidAdbProcess, @@ -746,3 +752,62 @@ test('a structured ok=false viewport response stops the session before the one-s assert.deepEqual(viewportResult, { viewport: { x: 5, y: 6, width: 300, height: 400 } }); assert.ok(oneShotArgs?.includes('viewport')); }); + +// contracts/fixtures/dispatch-disclosure.json, persistent-session gesture rows. + +async function sessionGestureFailure(answerGesture: (requestId: string) => string): Promise { + const device = makeIsolatedDevice(); + await startFakeTouchHelperSession(device, (command, requestId) => + command.startsWith('gesture') + ? answerGesture(requestId) + : sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'true', + }), + ); + await withAndroidAdbProvider( + { exec: currentVersionAdb(async () => ({ exitCode: 0, stdout: '', stderr: '' })) }, + { serial: device.id }, + async () => await executeAndroidTouchHelperPlan(device, lowerAndroidTouchPlan(flingPlan())), + ); +} + +const SESSION_DISPATCH_DRIVERS: Record Promise> = { + 'android-helper.gesture-session.reported-failure': () => + sessionGestureFailure((requestId) => + sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'false', + errorType: 'java.lang.IllegalStateException', + message: 'injectInputEvent returned false', + }), + ), + 'android-helper.gesture-session.transport-failure': () => + sessionGestureFailure(() => 'not a session response'), +}; + +const SESSION_DISPATCH_ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every persistent-session gesture dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows( + SESSION_DISPATCH_ROWS, + Object.keys(SESSION_DISPATCH_DRIVERS), + ); +}); + +for (const row of SESSION_DISPATCH_ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = SESSION_DISPATCH_DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index 8b3bf74185..8c01f23ad9 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -17,7 +17,7 @@ import { } from '@agent-device/contracts/scroll-gesture'; import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { sleep } from '@agent-device/host-kit/retry'; import { runAndroidShell } from './adb.ts'; @@ -25,7 +25,25 @@ import { executeAndroidTouchPlan, readAndroidGestureViewportReading } from './to import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise { - await runAndroidShell(device, ['input', 'tap', x, y]); + try { + await runAndroidShell(device, ['input', 'tap', x, y]); + } catch (error) { + throw discloseAdbInputDispatch(error); + } +} + +/** + * An `adb shell input` failure after `dispatchedSteps` earlier inputs succeeded: adb that never + * started delivered nothing; once any input ran, the event may have reached the device. + */ +export function discloseAdbInputDispatch(error: unknown, dispatchedSteps = 0): unknown { + if (!(error instanceof AppError)) return error; + const neverStarted = error.code === 'TOOL_MISSING' && dispatchedSteps === 0; + return discloseDispatch( + error, + neverStarted ? 'no' : 'unknown', + dispatchedSteps > 0 ? { dispatchedSteps } : {}, + ); } export async function pressAndroidTvRemote( diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index 3b05cb5176..17d503ed28 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -3,7 +3,7 @@ * that piggyback on it. Session ownership itself — starting, reusing, retiring — belongs to * `snapshot-helper-session-lifecycle.ts`, which this module acquires through. */ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, type DispatchDisclosure, discloseDispatch } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { readAndroidCaptureFailureReason } from '@agent-device/contracts/android-snapshot-quality'; import type { @@ -89,9 +89,21 @@ async function captureFromAndroidSnapshotHelperSession(params: { // (a second instrumentation for the helper package would force-stop the session that has it). They // never start a session: without one, callers use the same helper APK through a one-shot // `am instrument` run instead. +export type AndroidTouchHelperAction = 'gesture' | 'viewport'; + +/** Only a gesture injects input, so only its failures say whether input reached the device. */ +export function discloseHelperTouchDispatch( + action: AndroidTouchHelperAction, + error: unknown, + dispatched: DispatchDisclosure, +): unknown { + if (action !== 'gesture' || !(error instanceof AppError)) return error; + return discloseDispatch(error, dispatched); +} + export async function runAndroidSnapshotHelperSessionTouchCommand(params: { deviceKey: string; - action: 'gesture' | 'viewport'; + action: AndroidTouchHelperAction; helper: AndroidSnapshotHelperSessionHelperIdentity; payloadBase64?: string; timeoutMs: number; @@ -131,14 +143,18 @@ export async function runAndroidSnapshotHelperSessionTouchCommand(params: { // Transport-level failure: the session process can no longer be trusted. Stop it so the next // command runs against a fresh helper instead of a wedged socket. await stopAndroidSnapshotHelperSession(params.deviceKey); - throw error; + throw discloseHelperTouchDispatch(params.action, error, 'unknown'); } if (headers.ok !== 'true') { // The helper ran and reported a structured failure; the session itself stays healthy. - throw new AppError( - 'COMMAND_FAILED', - headers.message || headers.errorType || `Android automation helper ${params.action} failed`, - { errorType: headers.errorType, helper: headers }, + throw discloseHelperTouchDispatch( + params.action, + new AppError( + 'COMMAND_FAILED', + headers.message || headers.errorType || `Android automation helper ${params.action} failed`, + { errorType: headers.errorType, helper: headers }, + ), + 'yes', ); } return headers; diff --git a/packages/platform-android/src/text-input.ts b/packages/platform-android/src/text-input.ts index 01e7bec648..7915bdcba2 100644 --- a/packages/platform-android/src/text-input.ts +++ b/packages/platform-android/src/text-input.ts @@ -31,7 +31,7 @@ import { sendAndroidImeHelperText, } from './ime-helper.ts'; import { isAndroidTestImeActive } from './ime-lifecycle.ts'; -import { focusAndroid } from './input-actions.ts'; +import { discloseAdbInputDispatch, focusAndroid } from './input-actions.ts'; import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; /** @@ -269,17 +269,27 @@ async function typeAndroidShell( options: { action: AndroidTextInputAction; text: string; chunkSize: number; delayMs: number }, ): Promise { const parts = options.text.split('\n'); - for (const [partIndex, part] of parts.entries()) { - const chunks = chunkAndroidInputText(part, options.chunkSize); - for (const [chunkIndex, chunk] of chunks.entries()) { - await typeAndroidShellChunk(device, chunk); - if (options.delayMs > 0 && (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length)) { - await sleep(options.delayMs); + let dispatchedSteps = 0; + try { + for (const [partIndex, part] of parts.entries()) { + const chunks = chunkAndroidInputText(part, options.chunkSize); + for (const [chunkIndex, chunk] of chunks.entries()) { + await typeAndroidShellChunk(device, chunk); + dispatchedSteps += 1; + if ( + options.delayMs > 0 && + (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length) + ) { + await sleep(options.delayMs); + } + } + if (partIndex + 1 < parts.length) { + await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); + dispatchedSteps += 1; } } - if (partIndex + 1 < parts.length) { - await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); - } + } catch (error) { + throw discloseAdbInputDispatch(error, dispatchedSteps); } emitAndroidTextDiagnostic(options.action, 'adb-shell', options.text); } diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index 0cadd3ba0f..d53ba4d6c8 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -23,7 +23,11 @@ import type { AndroidLoweredTouchPlan } from './touch-plan-lowering.ts'; import { resolveAndroidHelperArtifact } from './helper-package-install.ts'; import { parseAndroidSnapshotHelperManifest } from './snapshot-helper-artifact.ts'; import { ensureAndroidSnapshotHelper } from './snapshot-helper-install.ts'; -import { runAndroidSnapshotHelperSessionTouchCommand } from './snapshot-helper-session.ts'; +import { + discloseHelperTouchDispatch, + runAndroidSnapshotHelperSessionTouchCommand, + type AndroidTouchHelperAction, +} from './snapshot-helper-session.ts'; import { ensureAndroidSnapshotHelperSession, stopAndroidSnapshotHelperSession, @@ -100,7 +104,8 @@ export async function executeAndroidTouchHelperPlan( ...(await runOneShotTouchHelper({ adb: prepared.adb, runner: prepared.artifact.manifest.instrumentationRunner, - extraArgs: ['-e', 'mode', 'gesture', '-e', 'payloadBase64', payloadBase64], + action: 'gesture', + extraArgs: ['-e', 'payloadBase64', payloadBase64], timeoutMs, readResult: readGestureResult, })), @@ -160,7 +165,8 @@ export async function readAndroidTouchHelperViewportReading( return await runOneShotTouchHelper({ adb: prepared.adb, runner: prepared.artifact.manifest.instrumentationRunner, - extraArgs: ['-e', 'mode', 'viewport'], + action: 'viewport', + extraArgs: [], timeoutMs: HELPER_VIEWPORT_TIMEOUT_MS, readResult: readViewportResult, }); @@ -265,13 +271,14 @@ function toAndroidPlannedPointerTrajectory( async function runOneShotTouchHelper(options: { adb: AndroidAdbExecutor; runner: string; + action: AndroidTouchHelperAction; extraArgs: string[]; timeoutMs: number; readResult: (record: Record) => Result; }): Promise { const result = await runAdbShell( options.adb, - ['am', 'instrument', '-w', ...options.extraArgs, options.runner], + ['am', 'instrument', '-w', '-e', 'mode', options.action, ...options.extraArgs, options.runner], { allowFailure: true, timeoutMs: options.timeoutMs }, ); let finalRecord: Record; @@ -280,24 +287,36 @@ async function runOneShotTouchHelper(options: { } catch (error) { if (error instanceof AppError) { if (error.code === HELPER_REPORTED_FAILURE) { - throw new AppError('COMMAND_FAILED', error.message, error.details, error); + throw discloseHelperTouchDispatch( + options.action, + new AppError('COMMAND_FAILED', error.message, error.details, error), + 'yes', + ); } if (error.code !== HELPER_NO_FINAL_RESULT) throw error; } - throw new AppError( - 'COMMAND_FAILED', - result.exitCode === 0 - ? 'Android automation helper output could not be parsed' - : 'Android automation helper failed before returning parseable output', - execFailureDetails(result), - error, + throw discloseHelperTouchDispatch( + options.action, + new AppError( + 'COMMAND_FAILED', + result.exitCode === 0 + ? 'Android automation helper output could not be parsed' + : 'Android automation helper failed before returning parseable output', + execFailureDetails(result), + error, + ), + 'unknown', ); } if (result.exitCode !== 0) { - throw new AppError( - 'COMMAND_FAILED', - 'Android automation helper failed', - execFailureDetails(result, { helper: finalRecord }), + throw discloseHelperTouchDispatch( + options.action, + new AppError( + 'COMMAND_FAILED', + 'Android automation helper failed', + execFailureDetails(result, { helper: finalRecord }), + ), + 'yes', ); } return options.readResult(finalRecord); diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts new file mode 100644 index 0000000000..aeac69054e --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { afterEach, test, vi } from 'vitest'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; +import type { RunnerCommand } from '../runner-contract.ts'; +import { + isRetryableRunnerError, + isStructuredRunnerFailure, +} from '../runner-error-classification.ts'; +import { executeRunnerCommandWithSession, type RunnerSession } from '../runner-session.ts'; +import { RunnerCommandAccounting } from '../runner-session-types.ts'; +import { + startFakeRunnerServer, + type FakeRunnerCommandScript, + type FakeRunnerResponse, + type FakeRunnerServer, +} from './fake-runner-server.ts'; + +// contracts/fixtures/dispatch-disclosure.json, ios-runner rows: each row drives the real send stack +// (executeRunnerCommandWithSession → transport fetch) or the real lost-response recovery against a +// scripted fake runner, and asserts the `details.dispatched` the failure leaves with. + +let server: FakeRunnerServer | undefined; + +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +function runnerSession(port: number): RunnerSession { + return { + sessionId: `fake:${port}`, + device: IOS_SIMULATOR, + deviceId: IOS_SIMULATOR.id, + port, + xctestrunPath: '/tmp/fake.xctestrun', + jsonPath: '/tmp/fake.json', + testPromise: new Promise(() => {}), + child: { pid: process.pid, exitCode: null }, + state: 'ready', + commandCharges: new RunnerCommandAccounting(), + }; +} + +const TAP: RunnerCommand = { command: 'tap', x: 10, y: 10, commandId: 'cmd-1' }; + +async function replyFailure(code: string): Promise { + const script: FakeRunnerCommandScript = { tap: [{ kind: 'runnerError', code, message: code }] }; + server = await startFakeRunnerServer(script); + return await executeRunnerCommandWithSession( + IOS_SIMULATOR, + runnerSession(server.port), + TAP, + undefined, + 5_000, + ); +} + +/** The runner hangs up on the command, then answers the status probe with `status`. */ +async function lostResponse( + status: FakeRunnerResponse[], + command: RunnerCommand = TAP, +): Promise { + server = await startFakeRunnerServer({ tap: [{ kind: 'hangUp' }], status }); + const session = runnerSession(server.port); + const transportError = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + command, + undefined, + 5_000, + ).then( + () => assert.fail('the fake runner hangs up on the command'), + (error: unknown) => asAppError(error, 'COMMAND_FAILED'), + ); + assert.equal(isRetryableRunnerError(transportError), true); + assert.equal(isStructuredRunnerFailure(transportError), false); + return await handleRunnerTransportErrorAfterCommandSend({ + device: IOS_SIMULATOR, + session, + command, + transportError, + options: {}, + signal: undefined, + invalidationReason: 'transport_error_after_command_send', + invalidateSession: vi.fn(async () => {}), + }); +} + +function statusReply(data: Record): FakeRunnerResponse[] { + return [{ kind: 'ok', data }]; +} + +const DRIVERS: Record Promise> = { + 'ios-runner.reply.RUNNER_BUSY': () => replyFailure('RUNNER_BUSY'), + 'ios-runner.reply.RUNNER_WEDGED': () => replyFailure('RUNNER_WEDGED'), + 'ios-runner.reply.APP_NOT_RUNNING': () => replyFailure('APP_NOT_RUNNING'), + 'ios-runner.reply.SCROLL_KEYBOARD_OCCLUDES_SURFACE': () => + replyFailure('SCROLL_KEYBOARD_OCCLUDES_SURFACE'), + 'ios-runner.reply.ALERT_NOT_FOUND': () => replyFailure('ALERT_NOT_FOUND'), + 'ios-runner.reply.APP_SCREEN_WINDOW_UNRESOLVED': () => + replyFailure('APP_SCREEN_WINDOW_UNRESOLVED'), + 'ios-runner.reply.APP_SCREEN_UNRESOLVED': () => replyFailure('APP_SCREEN_UNRESOLVED'), + 'ios-runner.reply.APP_SCREEN_CAPTURE_UNRENDERABLE': () => + replyFailure('APP_SCREEN_CAPTURE_UNRENDERABLE'), + 'ios-runner.reply.MAIN_THREAD_TIMEOUT': () => replyFailure('MAIN_THREAD_TIMEOUT'), + 'ios-runner.reply.executed-failure': () => replyFailure('XCTEST_RECORDED_FAILURE'), + 'ios-runner.status.failed': () => + lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorMessage: 'tap failed' })), + 'ios-runner.status.failed-RUNNER_BUSY': () => + lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorCode: 'RUNNER_BUSY' })), + 'ios-runner.status.completed-without-retained-reply': () => + lostResponse(statusReply({ lifecycleState: 'completed' })), + 'ios-runner.status.accepted': () => lostResponse(statusReply({ lifecycleState: 'accepted' })), + 'ios-runner.status.started': () => lostResponse(statusReply({ lifecycleState: 'started' })), + 'ios-runner.status.notAccepted': () => + lostResponse(statusReply({ lifecycleState: 'notAccepted' })), + 'ios-runner.status.probe-failed': () => + lostResponse([{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'status failed' }]), + 'ios-runner.status.unavailable': () => + lostResponse([], { command: 'tap', x: 10, y: 10 } as RunnerCommand), +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every ios-runner dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index a53311e9b5..7989a7eba7 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, type DispatchDisclosure, discloseDispatch } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { emitDiagnostic } from './host.ts'; import { @@ -7,6 +7,7 @@ import { isRunnerResponseOk, readRunnerResponseData, type RunnerCommand, + type RunnerReportedErrorClass, type RunnerResponsePayload, } from './runner-contract.ts'; import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; @@ -15,8 +16,20 @@ import { executeRunnerCommandWithSession, type RunnerSession } from './runner-se type RunnerTransportRecovery = | { type: 'recovered'; data: Record; reason: string; lifecycleState?: string } - | { type: 'skipInvalidation'; error: AppError; reason: string; lifecycleState?: string } - | { type: 'retainInvalidation'; error?: AppError; reason: string; lifecycleState?: string }; + | { + type: 'skipInvalidation'; + error: AppError; + dispatched: DispatchDisclosure; + reason: string; + lifecycleState?: string; + } + | { + type: 'retainInvalidation'; + error?: AppError; + dispatched: DispatchDisclosure; + reason: string; + lifecycleState?: string; + }; type RunnerTransportRecoveryContext = { command: RunnerCommand; @@ -66,12 +79,15 @@ async function applyRunnerTransportRecovery( recovery: RunnerTransportRecovery | undefined, context: RunnerTransportRecoveryContext, ): Promise> { - if (!recovery) return await retainRunnerInvalidation(context, 'status_recovery_unavailable'); + if (!recovery) { + return await retainRunnerInvalidation(context, 'status_recovery_unavailable', 'unknown'); + } if (recovery.type === 'recovered') return recoverRunnerResponse(recovery, context); if (recovery.type === 'skipInvalidation') throw skipRunnerInvalidation(recovery, context); return await retainRunnerInvalidation( context, recovery.reason, + recovery.dispatched, recovery.lifecycleState, recovery.error, ); @@ -104,12 +120,13 @@ function skipRunnerInvalidation( reason: recovery.reason, lifecycleState: recovery.lifecycleState, }); - return recovery.error; + return discloseDispatch(recovery.error, recovery.dispatched); } async function retainRunnerInvalidation( context: RunnerTransportRecoveryContext, reason: string, + dispatched: DispatchDisclosure, lifecycleState?: string, error?: AppError, ): Promise { @@ -122,7 +139,7 @@ async function retainRunnerInvalidation( lifecycleState, }); await context.invalidateSession(context.session, context.invalidationReason); - throw error ?? context.transportError; + throw discloseDispatch(error ?? context.transportError, dispatched); } async function tryRecoverRunnerCommandAfterTransportError( @@ -156,7 +173,7 @@ async function tryRecoverRunnerCommandAfterTransportError( ...readinessPreflight, }, }); - return { type: 'retainInvalidation', reason: 'status_probe_failed' }; + return { type: 'retainInvalidation', reason: 'status_probe_failed', dispatched: 'unknown' }; } const lifecycleState = typeof status.lifecycleState === 'string' ? status.lifecycleState : ''; @@ -230,11 +247,19 @@ function handleRunnerCommandStatusRecovery( } if (lifecycleState === 'failed') { + // The journal's code means exactly what the same code means on a live response, so read it with + // the one classifier (#2484 follow-up): a `RUNNER_BUSY` recovered from the lifecycle journal must + // stay `COMMAND_FAILED` + retriable, or a polling `wait` sees an unclassified failure and + // surrenders its budget to a condition that clears on its own. + const classification = classifyRunnerReportedError( + typeof status.lifecycleErrorCode === 'string' ? status.lifecycleErrorCode : undefined, + ); return { type: 'skipInvalidation', reason: 'runner_reported_failure', lifecycleState, - error: runnerStatusFailureError(status, command, transportError, options), + dispatched: classification.details.dispatched, + error: runnerStatusFailureError(status, classification, command, transportError, options), }; } @@ -243,6 +268,7 @@ function handleRunnerCommandStatusRecovery( type: 'skipInvalidation', reason: 'command_still_in_flight', lifecycleState, + dispatched: 'unknown', error: runnerStatusInFlightError(lifecycleState, command, transportError, options), }; } @@ -251,6 +277,7 @@ function handleRunnerCommandStatusRecovery( type: 'retainInvalidation', reason: lifecycleState ? 'unknown_lifecycle_state' : 'missing_lifecycle_state', lifecycleState, + dispatched: 'unknown', error: new AppError( 'COMMAND_FAILED', `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, @@ -287,6 +314,7 @@ function handleCompletedRunnerStatus( return { type: 'skipInvalidation', error: transportError, + dispatched: 'yes', reason: 'read_only_completed_without_retained_response', lifecycleState: 'completed', }; @@ -296,6 +324,7 @@ function handleCompletedRunnerStatus( type: 'skipInvalidation', reason: 'completed_without_retained_response', lifecycleState: 'completed', + dispatched: 'yes', error: new AppError( 'COMMAND_FAILED', `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, @@ -316,12 +345,11 @@ function handleCompletedRunnerStatus( function runnerStatusFailureError( status: Record, + classification: RunnerReportedErrorClass, command: RunnerCommand, transportError: AppError, options: AppleRunnerCommandOptions, ): AppError { - const errorCode = - typeof status.lifecycleErrorCode === 'string' ? status.lifecycleErrorCode : undefined; const errorMessage = typeof status.lifecycleErrorMessage === 'string' ? status.lifecycleErrorMessage @@ -329,11 +357,6 @@ function runnerStatusFailureError( const hint = typeof status.lifecycleErrorHint === 'string' ? status.lifecycleErrorHint : undefined; const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - // The journal's code means exactly what the same code means on a live response, so read it with - // the one classifier (#2484 follow-up): a `RUNNER_BUSY` recovered from the lifecycle journal must - // stay `COMMAND_FAILED` + retriable, or a polling `wait` sees an unclassified failure and - // surrenders its budget to a condition that clears on its own. - const classification = classifyRunnerReportedError(errorCode); return new AppError( classification.code, errorMessage, diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 230f5dd02c..f03664a8b2 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -1,4 +1,9 @@ -import { AppError, createRequestCanceledError, toAppErrorCode } from '@agent-device/kernel/errors'; +import { + AppError, + createRequestCanceledError, + toAppErrorCode, + type DispatchDisclosure, +} from '@agent-device/kernel/errors'; import crypto from 'node:crypto'; import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contract'; import type { DeviceRotation } from '@agent-device/contracts/device'; @@ -27,6 +32,12 @@ export const RUNNER_BUSY_RUNNER_CODE = 'RUNNER_BUSY'; */ export const MAIN_THREAD_TIMEOUT_RUNNER_CODE = 'MAIN_THREAD_TIMEOUT'; +/** + * The runner's own code for a command it refused because abandoned main-thread work has occupied it + * past the wedge threshold (#1105). Like `RUNNER_BUSY`, the refused command never ran. + */ +export const RUNNER_WEDGED_RUNNER_CODE = 'RUNNER_WEDGED'; + /** * The runner's own code for a read whose session app is not running. No runner read launches the * app — a bare launch would drop the payload of a launch still pending, such as a deep link held @@ -260,10 +271,29 @@ const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlyMap [code, {}] as const), ]); +/** + * Runner codes whose reply proves something other than "the command executed and failed". The + * refusals answer before the command runs; `MAIN_THREAD_TIMEOUT` abandons work that may still land. + * Every other structured reply comes from a command the runner executed. + */ +const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new Map([ + [RUNNER_BUSY_RUNNER_CODE, 'no'], + [RUNNER_WEDGED_RUNNER_CODE, 'no'], + [APP_NOT_RUNNING_RUNNER_CODE, 'no'], + [SCROLL_KEYBOARD_OCCLUDES_SURFACE_RUNNER_CODE, 'no'], + [ALERT_NOT_FOUND_RUNNER_CODE, 'no'], + ...[...RUNNER_SCREEN_CAPTURE_REFUSAL_RUNNER_CODES].map((code) => [code, 'no'] as const), + [MAIN_THREAD_TIMEOUT_RUNNER_CODE, 'unknown'], +]); + /** Wire code plus the details every path must publish for one runner-reported error code. */ export type RunnerReportedErrorClass = Readonly<{ code: AppError['code']; - details: Readonly<{ runnerErrorCode?: string; retriable?: true }>; + details: Readonly<{ + runnerErrorCode?: string; + retriable?: true; + dispatched: DispatchDisclosure; + }>; }>; /** @@ -281,7 +311,14 @@ export function classifyRunnerReportedError( : DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES.get(runnerErrorCode); return Object.freeze({ code: diagnosticOnly ? 'COMMAND_FAILED' : toAppErrorCode(runnerErrorCode), - details: Object.freeze({ runnerErrorCode, ...diagnosticOnly }), + details: Object.freeze({ + runnerErrorCode, + ...diagnosticOnly, + dispatched: + (runnerErrorCode === undefined + ? undefined + : RUNNER_ERROR_CODE_DISPATCH.get(runnerErrorCode)) ?? 'yes', + }), }); } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 34ff472b59..fe5faad8c1 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -9,7 +9,11 @@ import { type IosDeveloperDiskImageState, type IosDeveloperModeState, } from './host.ts'; -import { MAIN_THREAD_TIMEOUT_RUNNER_CODE, RUNNER_BUSY_RUNNER_CODE } from './runner-contract.ts'; +import { + MAIN_THREAD_TIMEOUT_RUNNER_CODE, + RUNNER_BUSY_RUNNER_CODE, + RUNNER_WEDGED_RUNNER_CODE, +} from './runner-contract.ts'; export const RUNNER_CACHE_RECOVERY_HINT = 'If runner build products look stale or corrupted, run `pnpm clean:xcuitest` in a local checkout, or remove ~/.agent-device/apple-runner/derived, then retry.'; @@ -311,7 +315,7 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // threshold (#1105): only a restart cures it. The per-request recycle budget // still bounds how many boots one request pays for. reason: 'runner_main_thread_wedged', - match: { code: 'RUNNER_WEDGED' }, + match: { code: RUNNER_WEDGED_RUNNER_CODE }, verdicts: { sessionFatalReason: 'runner_main_thread_wedged' }, }, // ── Startup classification (#2680) ─────────────────────────────────────────────────────────── diff --git a/packages/platform-apple/src/snapshot-source/lifecycle.ts b/packages/platform-apple/src/snapshot-source/lifecycle.ts index 4c48a37a60..223ea44db2 100644 --- a/packages/platform-apple/src/snapshot-source/lifecycle.ts +++ b/packages/platform-apple/src/snapshot-source/lifecycle.ts @@ -316,7 +316,7 @@ function shouldDiscardSession( ): boolean { return ( (error.failureKind === 'cancelled' || error.failureKind === 'timeout') && - (error.details?.dispatched === true || previousSession !== session) + (error.details?.bridgeRequestSent === true || previousSession !== session) ); } diff --git a/packages/platform-apple/src/snapshot-source/transport.ts b/packages/platform-apple/src/snapshot-source/transport.ts index 18f82e4ec3..fdefafb297 100644 --- a/packages/platform-apple/src/snapshot-source/transport.ts +++ b/packages/platform-apple/src/snapshot-source/transport.ts @@ -33,13 +33,15 @@ export async function roundTripSnapshotBridge( const timeoutMs = remainingSnapshotSourceMs(input.deadline, 'bridge-request-deadline'); return await new Promise((resolve, reject) => { let settled = false; - let dispatched = false; + let bridgeRequestSent = false; const timer = setTimeout(() => { - finishReject(snapshotSourceError('timeout', 'bridge-request-deadline', { dispatched })); + finishReject( + snapshotSourceError('timeout', 'bridge-request-deadline', { bridgeRequestSent }), + ); input.socket.destroy(); }, timeoutMs); const onAbort = () => { - finishReject(snapshotSourceError('cancelled', 'abort-signal', { dispatched })); + finishReject(snapshotSourceError('cancelled', 'abort-signal', { bridgeRequestSent })); input.socket.destroy(); }; const onData = (chunk: unknown) => { @@ -103,7 +105,7 @@ export async function roundTripSnapshotBridge( input.deadline.signal?.addEventListener('abort', onAbort, { once: true }); try { if (input.deadline.signal?.aborted) throw snapshotSourceError('cancelled', 'abort-signal'); - dispatched = true; + bridgeRequestSent = true; input.socket.write(input.frame); } catch (error) { finishReject(asSnapshotSourceError(error)); diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 3095a7699e..840c9d49cb 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -40,6 +40,7 @@ "@agent-device/contracts/device-boot", "@agent-device/contracts/device-readiness-runtime", "@agent-device/contracts/device-shutdown-runtime", + "@agent-device/contracts/dispatch-disclosure-fixtures", "@agent-device/contracts/divergence", "@agent-device/contracts/durable-resource", "@agent-device/contracts/durable-resource-envelope", diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts new file mode 100644 index 0000000000..3d85a9cbf3 --- /dev/null +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { beforeEach, test, vi } from 'vitest'; +import { attachRefs } from '@agent-device/kernel/snapshot'; +import { AppError, type DispatchDisclosure } from '@agent-device/kernel/errors'; +import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; +import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; +import { makeAndroidSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { + getRuntimeBindings, + mockTapPoint, + resetGetRuntimeFixture, +} from '../../../__tests__/interaction-get-runtime-fixture.ts'; +import { handleInteractionCommands } from '../../index.ts'; +import { assertAndroidPressStayedInApp } from '../interaction-android-escape.ts'; +import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; + +// contracts/fixtures/dispatch-disclosure.json, daemon seam and post-action guard rows: the seam +// rows drive a real `press` through the daemon interaction handler with only the device touch +// mocked; the guard row drives the guard itself. + +vi.mock('../../../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + +beforeEach(() => { + resetGetRuntimeFixture(); +}); + +async function pressRef(ref: string): Promise { + const sessionStore = makeSessionStore(); + const session = makeSession('dispatch-disclosure'); + session.snapshot = { + nodes: attachRefs([ + { + index: 0, + type: 'XCUIElementTypeButton', + label: 'Continue', + rect: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + hittable: true, + }, + ]), + createdAt: Date.now(), + backend: 'xctest', + }; + sessionStore.set(session.name, session); + const response = await handleInteractionCommands({ + req: { token: 't', session: session.name, command: 'press', positionals: [ref], flags: {} }, + sessionName: session.name, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); + assert.ok(response && !response.ok, 'expected the press to fail'); + throw new AppError(response.error.code, response.error.message, response.error.details); +} + +async function pressAfterUnclassifiedTouchFailure( + details?: Record, +): Promise { + mockTapPoint.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'touch failed', details)); + return await pressRef('@e1'); +} + +async function pressThatLeftTheApp(): Promise { + const session = makeAndroidSession('dispatch-disclosure-android', { + appBundleId: 'com.example.app', + }); + const observation = { + readAppState: async () => ({ package: 'com.android.settings' }), + isPermissionPackage: async () => false, + } as unknown as AndroidObservationAdapter; + return await assertAndroidPressStayedInApp(session, '@e1', observation); +} + +const DRIVERS: Record Promise> = { + 'daemon.refusal-before-seam': async () => { + try { + return await pressRef('@e9'); + } finally { + assert.equal(mockTapPoint.mock.calls.length, 0, 'a refusal must not reach the device'); + } + }, + 'daemon.unclassified-after-seam': () => pressAfterUnclassifiedTouchFailure(), + 'post-action-guard.android-press-left-app': pressThatLeftTheApp, +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every daemon and post-action guard dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} + +test('the seam keeps a producer verdict instead of inferring its own', async () => { + for (const dispatched of ['no', 'yes'] satisfies DispatchDisclosure[]) { + await assert.rejects(pressAfterUnclassifiedTouchFailure({ dispatched }), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, dispatched); + return true; + }); + } +}); diff --git a/src/daemon/interaction/internal/interaction-android-escape.ts b/src/daemon/interaction/internal/interaction-android-escape.ts index 4f923396be..e76c6a2c35 100644 --- a/src/daemon/interaction/internal/interaction-android-escape.ts +++ b/src/daemon/interaction/internal/interaction-android-escape.ts @@ -24,7 +24,7 @@ export async function assertAndroidPressStayedInApp( throw new AppError( 'COMMAND_FAILED', `press ${targetLabel} left ${session.appBundleId} and foregrounded ${surface.foregroundPackage}. The tap likely escaped the app.`, - surface, + { ...surface, dispatched: 'yes' }, ); } diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts new file mode 100644 index 0000000000..e6eb2ed82b --- /dev/null +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -0,0 +1,38 @@ +import { + AppError, + type DispatchDisclosure, + discloseUnclassifiedDispatch, +} from '@agent-device/kernel/errors'; +import type { DaemonResponse } from '../../daemon-request.ts'; +import { readSessionRuntimeRevision } from '../../ref-frame.ts'; +import type { SessionState } from '../../session-state.ts'; + +/** + * The ADR 0014 side-effect seam as the dispatch verdict for an interaction failure no producer + * classified: a failure before this request crossed the seam never reached the device (`no`); one + * after it may have (`unknown`). A producer's own verdict is kept. + */ +export async function discloseDispatchAtSideEffectSeam( + session: SessionState | undefined, + dispatch: () => Promise, +): Promise { + const revisionBeforeDispatch = session ? readSessionRuntimeRevision(session) : undefined; + const seamVerdict = (): DispatchDisclosure => + session && readSessionRuntimeRevision(session) !== revisionBeforeDispatch ? 'unknown' : 'no'; + try { + const response = await dispatch(); + if (!response || response.ok || response.error.details?.dispatched !== undefined) { + return response; + } + return { + ok: false, + error: { + ...response.error, + details: { ...response.error.details, dispatched: seamVerdict() }, + }, + }; + } catch (error) { + if (error instanceof AppError) throw discloseUnclassifiedDispatch(error, seamVerdict()); + throw error; + } +} diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 1cc82a0a63..438369b0ff 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -4,6 +4,7 @@ import { type RequestCaptureProof, withCaptureDisclosures } from '../../capture- import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { dispatchFillViaRuntime } from './interaction-touch-fill.ts'; import { dispatchTargetedTouchViaRuntime } from './interaction-touch-press.ts'; +import { discloseDispatchAtSideEffectSeam } from './interaction-dispatch-disclosure.ts'; import { finalizeTouchInteraction } from './interaction-runtime.ts'; import { refSnapshotFlagGuardResponse } from '../../ref-snapshot-flag-policy.ts'; import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../../selector-runtime.ts'; @@ -26,7 +27,10 @@ export async function handleInteractionCommands( ): Promise { const captureProof: RequestCaptureProof = {}; const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; - const response = await dispatchInteractionCommand(routed); + const response = await discloseDispatchAtSideEffectSeam( + params.sessionStore.get(params.sessionName), + async () => await dispatchInteractionCommand(routed), + ); return response ? withCaptureDisclosures({ response, consumedTree: captureProof, captureProof }) : response; diff --git a/test/integration/interaction-contract/runtime-selector.contract.test.ts b/test/integration/interaction-contract/runtime-selector.contract.test.ts index 90c03366a8..5f8598c0f2 100644 --- a/test/integration/interaction-contract/runtime-selector.contract.test.ts +++ b/test/integration/interaction-contract/runtime-selector.contract.test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import type { InteractionGuarantee } from '@agent-device/contracts/interaction-guarantees'; import type { Point } from '@agent-device/kernel/snapshot'; import { selector } from '../../../src/commands/interaction/runtime/selector-read-utils.ts'; -import { assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts'; import { scenarioName, scenarioNames } from './coverage-manifest.ts'; import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts'; @@ -290,6 +290,21 @@ test(scenario('errorTaxonomy'), async () => { ); }); +test( + scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'errorTaxonomy')[1]!, + async () => { + await withIosContractDaemon( + [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerSnapshotEntry(RUNNER_CONTINUE_NODES)], + async (daemon) => { + const press = await daemon.callCommand('press', ['label=Missing']); + const error = assertRpcError(press, 'COMMAND_FAILED', /Selector did not match/); + assert.equal((error.details as Record)?.dispatched, 'no'); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); + test(scenario('responseIdentity'), async () => { const device = createContractDevice(continueButtonSnapshot(), { tap: async () => ({ ok: true }), diff --git a/test/integration/interaction-contract/runtime-selector.coverage.ts b/test/integration/interaction-contract/runtime-selector.coverage.ts index 0d968bc93f..90855d4dae 100644 --- a/test/integration/interaction-contract/runtime-selector.coverage.ts +++ b/test/integration/interaction-contract/runtime-selector.coverage.ts @@ -23,8 +23,10 @@ export const RUNTIME_SELECTOR_COVERAGE = definePathCoverage('runtime-selector', 'runtime-selector verifyEvidence: press --verify returns a digest with change detection', settleObservation: 'runtime-selector settleObservation: press --settle returns the settled diff with fresh refs', - errorTaxonomy: + errorTaxonomy: [ 'runtime-selector errorTaxonomy: no-match failure carries the shared code and hint', + 'runtime-selector errorTaxonomy: a daemon selector miss discloses dispatched no', + ], resolutionDisclosure: [ 'runtime-selector resolutionDisclosure: a unique match discloses the unique runtime shape', 'runtime-selector resolutionDisclosure: an equivalent wrapper chain discloses matchCount, winnerDiagnostic, and structural equivalence', diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 0420731b2e..4d63a25cf1 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -41,11 +41,13 @@ "packages/kernel/src/contracts.ts#jsonRpcRequestSchema": "sha256:67e6b8a28b39a3883424a565ae7033c336dc2ea6b193e9b32bb98eb3e18dca7c", "packages/kernel/src/device.ts#PLATFORM_SELECTORS": "sha256:36e9da1cc660c0ddfb4cf52521f7f230341ff20e3ebda3405ba7c1799476c42d", "packages/kernel/src/device.ts#PlatformSelector": "sha256:61de3f003507ea2f53b396b0146c17670bf674a2db2132e19c462ca6c10cc8fd", - "packages/kernel/src/errors.ts#DaemonError": "sha256:ae49c9c9c8fb93bb6b31ab865f37cc8b0db6c1b6ff026cc2791425b772c162a6", + "packages/kernel/src/errors.ts#DaemonError": "sha256:d6cb31f516102d147bd4e4058ea4ace1d48584280f5b49205234c6dd5ada6e1c", "packages/kernel/src/errors.ts#DiagnosticsRecordRef": "sha256:c5ef4185d01814fbfddcdcf797ce892f6ebfe1fd3c3fa73c7560ce2d3b6fce60", + "packages/kernel/src/errors.ts#DispatchDisclosure": "sha256:c1ec9dcec06b23bb3b001fd03d6211d432b9635049d9ca2369a825c5a2898e8e", "packages/kernel/src/errors.ts#ErrorCause": "sha256:2f38679b0e9ec997fe8d94b5d5025404fd6ebb21f2c91ed2ab5d4133e578dfc4", + "packages/kernel/src/errors.ts#ErrorWireDetails": "sha256:79169f66b0935b8140c0e8329a8cdfd1db368fdadf34deeed404ea5ae7b6afe0", "packages/kernel/src/errors.ts#NormalizeErrorContext": "sha256:98567378fc456335c8751474152edcf989e676ad0d9f4b54afdd8e6a96b03ae2", - "packages/kernel/src/errors.ts#NormalizedError": "sha256:dfe0e0066292d29eec7f6defea648dc488a6c0b62c268c3dac8233f8556e4bbb", + "packages/kernel/src/errors.ts#NormalizedError": "sha256:99d880cce96cf364c4937045c704f2e34a870fef37af35c2115df33f8e8355af", "packages/kernel/src/errors.ts#normalizeError": "sha256:5d9f906fd326676b4c3095cd6bd675c784cb26f36f4b8344a108cf5f7628f9dc", "packages/kernel/src/errors.ts#readDiagnosticsRecordRef": "sha256:3a210f401221c0a15eb75d488450034885c7b73be39f5e93424c72874d14c395", "src/commands/cli-grammar/types.ts#DaemonCommandRequest": "sha256:ea3f4118244711f0eaec0e471f62b0de42ca97181c085df165d37b63301a4619", @@ -224,13 +226,13 @@ }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", - "digest": "sha256:ae49c9c9c8fb93bb6b31ab865f37cc8b0db6c1b6ff026cc2791425b772c162a6", - "rationale": "#1801 and #1862 add optional error fields, most recently the structured `cause`. A peer on protocol 2 that does not send them is unchanged, and one that does not read them keeps parsing every field it read before." + "digest": "sha256:d6cb31f516102d147bd4e4058ea4ace1d48584280f5b49205234c6dd5ada6e1c", + "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | yes | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." }, { "declaration": "packages/kernel/src/errors.ts#NormalizedError", - "digest": "sha256:dfe0e0066292d29eec7f6defea648dc488a6c0b62c268c3dac8233f8556e4bbb", - "rationale": "#1801 and #1862 add optional fields to the normalized error the daemon serializes, most recently `cause`; they are omitted unless set, and a released client ignores them otherwise." + "digest": "sha256:99d880cce96cf364c4937045c704f2e34a870fef37af35c2115df33f8e8355af", + "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | yes | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody", diff --git a/test/wire-compat/surface.ts b/test/wire-compat/surface.ts index e0354da89e..4929f65732 100644 --- a/test/wire-compat/surface.ts +++ b/test/wire-compat/surface.ts @@ -272,6 +272,8 @@ export const WIRE_SURFACE: readonly WireSurfaceGroup[] = [ ...from( KERNEL_ERRORS, 'DaemonError', + 'ErrorWireDetails', + 'DispatchDisclosure', 'DiagnosticsRecordRef', 'ErrorCause', 'readDiagnosticsRecordRef', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b5e07e51c5..4c9090135d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -488,6 +488,7 @@ agent-device gesture transform 200 420 80 -40 2 35 700 # combined pan, zoom, and ``` `fill` clears then types. `type` does not clear. +A failed interaction carries `error.details.dispatched` when its producer could classify it: `no` means the action provably never reached the device, `yes` means it executed and failed, and `unknown` means it may have landed, so observe the screen before retrying; a failure without the field was not classified. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. On iOS, if `type "\n"` reports `TEXT_INPUT_SYNTHESIS_UNAVAILABLE` after tapping a field while the software keyboard is hidden, show the software keyboard, then retry. The runner reports this error instead of risking input through an unreliable text-entry path. From 206fa983619adcab0c214435ad1cb98767d1b4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 10:57:28 +0200 Subject: [PATCH 02/42] fix(errors): only a pre-dispatch refusal discloses dispatched no The daemon fallback now fills unknown for any failure no producer classified; the session runtime revision was not a sound witness (it does not advance for sessionless requests, a replaced session resets it, and several side effects never advance it). Target resolution, keyboard occlusion, and targeted-touch admission stamp no where they refuse, and the covered-target refusal gains details.reason target_covered. --- contracts/fixtures/dispatch-disclosure.json | 22 ++++++-- .../0011-interaction-guarantee-contract.md | 12 +++-- packages/selectors/src/interaction-error.ts | 2 + .../interaction/runtime/keyboard-occlusion.ts | 23 ++++---- .../runtime/ref-target-resolution.test.ts | 3 +- .../runtime/ref-target-resolution.ts | 26 ++++----- .../runtime/replay-target-guard.ts | 27 +++++----- .../runtime/resolution-touch-point.ts | 36 +++++++------ .../interaction/runtime/selector-readiness.ts | 19 ++++--- .../runtime/target-visibility-stages.ts | 42 +++++++++------ .../interaction-dispatch-disclosure.test.ts | 54 +++++++++++++------ .../interaction-dispatch-disclosure.ts | 34 ++++++------ .../interaction-touch-press-admission.ts | 11 ++++ .../interaction/internal/interaction.ts | 5 +- .../runtime-selector.contract.test.ts | 10 +++- 15 files changed, 206 insertions(+), 120 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 59b235d91f..9fdee7768a 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -1,16 +1,30 @@ [ { - "id": "daemon.refusal-before-seam", + "id": "daemon.refusal.ref-not-found", "phase": "before-seam", "producer": "daemon", - "trigger": "interaction failure raised before expireRefFrame advanced the session runtime revision", + "trigger": "target resolution refused the @ref (refMissRefusal) before any backend call", "dispatched": "no" }, { - "id": "daemon.unclassified-after-seam", + "id": "daemon.refusal.admission", + "phase": "before-seam", + "producer": "daemon", + "trigger": "targeted-touch admission refused the request (admitTargetedTouch) before any backend call", + "dispatched": "no" + }, + { + "id": "daemon.unclassified", + "phase": "after-seam", + "producer": "daemon", + "trigger": "an interaction failure no producer classified; the fallback is unknown whatever the session runtime revision did", + "dispatched": "unknown" + }, + { + "id": "daemon.unclassified.session-replaced", "phase": "after-seam", "producer": "daemon", - "trigger": "interaction failure raised after expireRefFrame with no producer verdict", + "trigger": "an unclassified failure after the session object was replaced mid-request", "dispatched": "unknown" }, { diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 0191c58ae0..ad2848bd27 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -152,11 +152,13 @@ without needing a simulator. `contracts/fixtures/dispatch-disclosure.json` is the table for `AppErrorDetails.dispatched` on interaction failures: one row per producer -event, each with the value it must leave. The ADR 0014 side-effect seam is the -anchor: a failure raised before this request advanced the session runtime -revision is `no`, one raised after it is `unknown` unless a producer proved -`yes` (or `no`) first; the daemon applies that rule once, around interaction -dispatch, and never overwrites a producer's value. Each row names its driver +event, each with the value it must leave. Only a producer that refuses before +dispatch (target resolution, admission, a runner pre-send refusal) may say +`no`, and only one that proved execution may say `yes`. The daemon fills +`unknown` once, around interaction dispatch, for a failure no producer +classified, and never overwrites a producer's value: a wrong `no` makes a +consumer resend an action that already ran, while a wrong `unknown` only costs +an observation. Each row names its driver file by id prefix, that file drives the real producer, and a row marked `implementedBy` waits for the branch that ships it. diff --git a/packages/selectors/src/interaction-error.ts b/packages/selectors/src/interaction-error.ts index 593173ebaf..ddb9924985 100644 --- a/packages/selectors/src/interaction-error.ts +++ b/packages/selectors/src/interaction-error.ts @@ -18,4 +18,6 @@ export const INTERACTION_ERROR_REASONS = { * `details.snapshotQuality` carries the verdict. */ captureSparse: 'capture_sparse', + /** The target resolved, but another visible element covers it. */ + targetCovered: 'target_covered', } as const; diff --git a/src/commands/interaction/runtime/keyboard-occlusion.ts b/src/commands/interaction/runtime/keyboard-occlusion.ts index edb28812bf..1491ff5892 100644 --- a/src/commands/interaction/runtime/keyboard-occlusion.ts +++ b/src/commands/interaction/runtime/keyboard-occlusion.ts @@ -5,7 +5,7 @@ import type { SnapshotNode, SnapshotState, } from '@agent-device/kernel/snapshot'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; import { resolveKeyboardTapOcclusion, @@ -110,14 +110,17 @@ function buildKeyboardOcclusionError(params: { targetRect: NonNullable; surface: KeyboardSurface; }): AppError { - return new AppError( - 'COMMAND_FAILED', - `${params.label} is behind the visible keyboard and cannot ${interactionVerb(params.action)} safely`, - { - ...TAP_KEYBOARD_OCCLUDES_TARGET_DETAILS, - ref: `@${params.ref}`, - rect: params.targetRect, - keyboardFrame: params.surface.frame, - }, + return discloseDispatch( + new AppError( + 'COMMAND_FAILED', + `${params.label} is behind the visible keyboard and cannot ${interactionVerb(params.action)} safely`, + { + ...TAP_KEYBOARD_OCCLUDES_TARGET_DETAILS, + ref: `@${params.ref}`, + rect: params.targetRect, + keyboardFrame: params.surface.frame, + }, + ), + 'no', ); } diff --git a/src/commands/interaction/runtime/ref-target-resolution.test.ts b/src/commands/interaction/runtime/ref-target-resolution.test.ts index a8e942e409..72b4d4535e 100644 --- a/src/commands/interaction/runtime/ref-target-resolution.test.ts +++ b/src/commands/interaction/runtime/ref-target-resolution.test.ts @@ -38,7 +38,7 @@ test('runtime ref interactions fail closed when the authorized ref has no usable assert.match((error as Error).message, /Ref @e1 has no usable bounds/); assert.deepEqual( (error as { details?: Record }).details, - { reason: 'target_bounds_invalid', ref: 'e1', hint: STALE_REF_HINT }, + { reason: 'target_bounds_invalid', ref: 'e1', hint: STALE_REF_HINT, dispatched: 'no' }, 'the frame lists @e1, so the refusal names the bounds, not a missing ref', ); return true; @@ -69,6 +69,7 @@ test('runtime ref interactions refuse a ref the authorized frame does not list w reason: 'ref_not_found', ref: 'e9', hint: STALE_REF_HINT, + dispatched: 'no', }); return true; }, diff --git a/src/commands/interaction/runtime/ref-target-resolution.ts b/src/commands/interaction/runtime/ref-target-resolution.ts index cd4333d7fa..ae117d9eb1 100644 --- a/src/commands/interaction/runtime/ref-target-resolution.ts +++ b/src/commands/interaction/runtime/ref-target-resolution.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { SnapshotKeyboardBandFact, SnapshotNode, @@ -243,17 +243,19 @@ function refMissRefusal( refInput: string, ): AppError { const ref = normalizeRef(refInput) ?? refInput; - return miss.kind === 'unusable' - ? new AppError('COMMAND_FAILED', `Ref ${refInput} has no usable bounds`, { - reason: INTERACTION_ERROR_REASONS.targetBoundsInvalid, - ref, - hint: STALE_REF_HINT, - }) - : new AppError('COMMAND_FAILED', `Ref ${refInput} not found`, { - reason: INTERACTION_ERROR_REASONS.refNotFound, - ref, - hint: STALE_REF_HINT, - }); + const refusal = + miss.kind === 'unusable' + ? new AppError('COMMAND_FAILED', `Ref ${refInput} has no usable bounds`, { + reason: INTERACTION_ERROR_REASONS.targetBoundsInvalid, + ref, + hint: STALE_REF_HINT, + }) + : new AppError('COMMAND_FAILED', `Ref ${refInput} not found`, { + reason: INTERACTION_ERROR_REASONS.refNotFound, + ref, + hint: STALE_REF_HINT, + }); + return discloseDispatch(refusal, 'no'); } function isUsableResolvedNode(node: SnapshotNode | null | undefined): node is SnapshotNode { diff --git a/src/commands/interaction/runtime/replay-target-guard.ts b/src/commands/interaction/runtime/replay-target-guard.ts index ba4cff4ece..52f9e2fd61 100644 --- a/src/commands/interaction/runtime/replay-target-guard.ts +++ b/src/commands/interaction/runtime/replay-target-guard.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { localIdentitiesEqual, @@ -36,17 +36,20 @@ export function assertExpectedResolvedTarget( ) { return; } - throw new AppError( - 'COMMAND_FAILED', - `${action} resolved to a different element than replay verification isolated; the action was not sent`, - { - reason: REPLAY_TARGET_GUARD_MISMATCH_REASON, - observed: observedIdentity, - observedStructural, - expected: expected.identity, - expectedStructural: expected.structural, - ...(targetRole ? { targetRole } : {}), - }, + throw discloseDispatch( + new AppError( + 'COMMAND_FAILED', + `${action} resolved to a different element than replay verification isolated; the action was not sent`, + { + reason: REPLAY_TARGET_GUARD_MISMATCH_REASON, + observed: observedIdentity, + observedStructural, + expected: expected.identity, + expectedStructural: expected.structural, + ...(targetRole ? { targetRole } : {}), + }, + ), + 'no', ); } diff --git a/src/commands/interaction/runtime/resolution-touch-point.ts b/src/commands/interaction/runtime/resolution-touch-point.ts index 6e4ab7bd6f..43a68e4532 100644 --- a/src/commands/interaction/runtime/resolution-touch-point.ts +++ b/src/commands/interaction/runtime/resolution-touch-point.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { Point, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { normalizeRef } from '@agent-device/kernel/snapshot'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; @@ -22,21 +22,27 @@ export function resolveNodeTouchPoint( }); if (resolution.kind === 'resolved') return resolution.point; if (resolution.kind === 'invalid') { - throw new AppError('COMMAND_FAILED', failure.invalidMessage, { - reason: INTERACTION_ERROR_REASONS.targetBoundsInvalid, - ...bareTargetDetails(failure.blockedTargetDetails), - }); + throw discloseDispatch( + new AppError('COMMAND_FAILED', failure.invalidMessage, { + reason: INTERACTION_ERROR_REASONS.targetBoundsInvalid, + ...bareTargetDetails(failure.blockedTargetDetails), + }), + 'no', + ); } - throw new AppError( - 'COMMAND_FAILED', - `${failure.blockedTargetLabel} has no parent-owned touch point outside its interactive descendants`, - { - reason: 'covered_by_interactive_descendants', - ...failure.blockedTargetDetails, - competitorRefs: resolution.competitorRefs.slice(0, 5).map((ref) => `@${ref}`), - competitorCount: resolution.competitorRefs.length, - hint: 'Tap the specific interactive child you intend, or use a more specific selector. Every safely tappable region of the parent belongs to one of its child controls.', - }, + throw discloseDispatch( + new AppError( + 'COMMAND_FAILED', + `${failure.blockedTargetLabel} has no parent-owned touch point outside its interactive descendants`, + { + reason: 'covered_by_interactive_descendants', + ...failure.blockedTargetDetails, + competitorRefs: resolution.competitorRefs.slice(0, 5).map((ref) => `@${ref}`), + competitorCount: resolution.competitorRefs.length, + hint: 'Tap the specific interactive child you intend, or use a more specific selector. Every safely tappable region of the parent belongs to one of its child controls.', + }, + ), + 'no', ); } diff --git a/src/commands/interaction/runtime/selector-readiness.ts b/src/commands/interaction/runtime/selector-readiness.ts index 758f09cf9d..61952fc1cf 100644 --- a/src/commands/interaction/runtime/selector-readiness.ts +++ b/src/commands/interaction/runtime/selector-readiness.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { inheritPostGestureOutcome } from '@agent-device/kernel/snapshot'; import { @@ -146,13 +146,16 @@ export async function selectorInteractionFailure(params: { const covered = await detectCoveredSelectorTarget({ runtime, nodes, selectorExpression, action }); if (covered) return covered; const diagnostics = resolved?.diagnostics ?? []; - return new AppError( - 'COMMAND_FAILED', - formatSelectorFailure(selectorExpression, diagnostics, { unique: true }), - { - reason: INTERACTION_ERROR_REASONS.selectorNotFound, - hint: selectorFailureHint(diagnostics), - }, + return discloseDispatch( + new AppError( + 'COMMAND_FAILED', + formatSelectorFailure(selectorExpression, diagnostics, { unique: true }), + { + reason: INTERACTION_ERROR_REASONS.selectorNotFound, + hint: selectorFailureHint(diagnostics), + }, + ), + 'no', ); } diff --git a/src/commands/interaction/runtime/target-visibility-stages.ts b/src/commands/interaction/runtime/target-visibility-stages.ts index 5963dd1a42..2c24cc58a1 100644 --- a/src/commands/interaction/runtime/target-visibility-stages.ts +++ b/src/commands/interaction/runtime/target-visibility-stages.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { Point, SnapshotKeyboardBandFact, @@ -13,6 +13,7 @@ import { } from '@agent-device/selectors/selector-pipeline'; import type { SelectorPipelinePolicy } from '@agent-device/selectors/selector-pipeline-policy'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; +import { INTERACTION_ERROR_REASONS } from '@agent-device/selectors/interaction-error'; import { classifyOffscreenScrollDirection, type OffscreenScrollDirection, @@ -33,15 +34,19 @@ export function buildCoveredInteractionError(params: { action: InteractionAction; selector?: string; }): AppError { - return new AppError( - 'COMMAND_FAILED', - `${params.label} is covered by another visible element and cannot ${interactionVerb(params.action)} safely`, - { - hint: 'Use a different visible target, scroll it clear of the overlay, or inspect with snapshot/screenshot before retrying.', - ...(params.selector ? { selector: params.selector } : {}), - ref: `@${params.node.ref}`, - interactionBlocked: params.node.interactionBlocked, - }, + return discloseDispatch( + new AppError( + 'COMMAND_FAILED', + `${params.label} is covered by another visible element and cannot ${interactionVerb(params.action)} safely`, + { + reason: INTERACTION_ERROR_REASONS.targetCovered, + hint: 'Use a different visible target, scroll it clear of the overlay, or inspect with snapshot/screenshot before retrying.', + ...(params.selector ? { selector: params.selector } : {}), + ref: `@${params.node.ref}`, + interactionBlocked: params.node.interactionBlocked, + }, + ), + 'no', ); } @@ -209,11 +214,14 @@ export async function throwIfOffscreenInteractionTarget( // boundary the rejection above used, so partial clips and off-screen // containers get a direction too, not just fully-scrolled-out items. const scrollDirection = classifyOffscreenScrollDirection(node, visibility); - throw new AppError('COMMAND_FAILED', failure.message, { - ...failure.details, - rect: node.rect, - viewport, - ...(scrollDirection ? { scrollDirection } : {}), - hint: failure.hint(scrollDirection), - }); + throw discloseDispatch( + new AppError('COMMAND_FAILED', failure.message, { + ...failure.details, + rect: node.rect, + viewport, + ...(scrollDirection ? { scrollDirection } : {}), + hint: failure.hint(scrollDirection), + }), + 'no', + ); } diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 3d85a9cbf3..893772d010 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -20,9 +20,9 @@ import { handleInteractionCommands } from '../../index.ts'; import { assertAndroidPressStayedInApp } from '../interaction-android-escape.ts'; import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; -// contracts/fixtures/dispatch-disclosure.json, daemon seam and post-action guard rows: the seam -// rows drive a real `press` through the daemon interaction handler with only the device touch -// mocked; the guard row drives the guard itself. +// contracts/fixtures/dispatch-disclosure.json, daemon and post-action guard rows: the daemon rows +// drive a real `press` through the daemon interaction handler with only the device touch mocked; +// the guard row drives the guard itself. vi.mock('../../../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -32,7 +32,13 @@ beforeEach(() => { resetGetRuntimeFixture(); }); -async function pressRef(ref: string): Promise { +type PressScenario = { + positionals: string[]; + /** Runs inside the device touch, before it fails. */ + duringTouch?: (store: ReturnType, sessionName: string) => void; +}; + +async function press({ positionals, duringTouch }: PressScenario): Promise { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure'); session.snapshot = { @@ -50,8 +56,14 @@ async function pressRef(ref: string): Promise { backend: 'xctest', }; sessionStore.set(session.name, session); + if (duringTouch) { + mockTapPoint.mockImplementationOnce(async () => { + duringTouch(sessionStore, session.name); + throw new AppError('COMMAND_FAILED', 'touch failed'); + }); + } const response = await handleInteractionCommands({ - req: { token: 't', session: session.name, command: 'press', positionals: [ref], flags: {} }, + req: { token: 't', session: session.name, command: 'press', positionals, flags: {} }, sessionName: session.name, sessionStore, contextFromFlags, @@ -61,11 +73,19 @@ async function pressRef(ref: string): Promise { throw new AppError(response.error.code, response.error.message, response.error.details); } +async function refusedPress(positionals: string[]): Promise { + try { + return await press({ positionals }); + } finally { + assert.equal(mockTapPoint.mock.calls.length, 0, 'a refusal must not reach the device'); + } +} + async function pressAfterUnclassifiedTouchFailure( details?: Record, ): Promise { mockTapPoint.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'touch failed', details)); - return await pressRef('@e1'); + return await press({ positionals: ['@e1'] }); } async function pressThatLeftTheApp(): Promise { @@ -80,14 +100,18 @@ async function pressThatLeftTheApp(): Promise { } const DRIVERS: Record Promise> = { - 'daemon.refusal-before-seam': async () => { - try { - return await pressRef('@e9'); - } finally { - assert.equal(mockTapPoint.mock.calls.length, 0, 'a refusal must not reach the device'); - } - }, - 'daemon.unclassified-after-seam': () => pressAfterUnclassifiedTouchFailure(), + 'daemon.refusal.ref-not-found': () => refusedPress(['@e9']), + 'daemon.refusal.admission': () => refusedPress([]), + 'daemon.unclassified': () => pressAfterUnclassifiedTouchFailure(), + 'daemon.unclassified.session-replaced': () => + press({ + positionals: ['@e1'], + duringTouch: (store, name) => { + const current = store.get(name); + assert.ok(current); + store.set(name, { ...current }); + }, + }), 'post-action-guard.android-press-left-app': pressThatLeftTheApp, }; @@ -112,7 +136,7 @@ for (const row of ROWS) { }); } -test('the seam keeps a producer verdict instead of inferring its own', async () => { +test('the daemon keeps a producer verdict instead of inferring its own', async () => { for (const dispatched of ['no', 'yes'] satisfies DispatchDisclosure[]) { await assert.rejects(pressAfterUnclassifiedTouchFailure({ dispatched }), (error: unknown) => { assert.ok(error instanceof AppError); diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts index e6eb2ed82b..e7c07bc463 100644 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -1,24 +1,15 @@ -import { - AppError, - type DispatchDisclosure, - discloseUnclassifiedDispatch, -} from '@agent-device/kernel/errors'; +import { AppError, discloseUnclassifiedDispatch } from '@agent-device/kernel/errors'; import type { DaemonResponse } from '../../daemon-request.ts'; -import { readSessionRuntimeRevision } from '../../ref-frame.ts'; -import type { SessionState } from '../../session-state.ts'; /** - * The ADR 0014 side-effect seam as the dispatch verdict for an interaction failure no producer - * classified: a failure before this request crossed the seam never reached the device (`no`); one - * after it may have (`unknown`). A producer's own verdict is kept. + * The verdict for an interaction failure no producer classified: `unknown`. Only a producer that + * refuses before dispatch may say `no`, and only one that proved execution may say `yes`; this + * layer cannot tell either from where the failure surfaced, so it keeps any producer's verdict and + * otherwise claims nothing. */ -export async function discloseDispatchAtSideEffectSeam( - session: SessionState | undefined, +export async function discloseUnclassifiedInteractionDispatch( dispatch: () => Promise, ): Promise { - const revisionBeforeDispatch = session ? readSessionRuntimeRevision(session) : undefined; - const seamVerdict = (): DispatchDisclosure => - session && readSessionRuntimeRevision(session) !== revisionBeforeDispatch ? 'unknown' : 'no'; try { const response = await dispatch(); if (!response || response.ok || response.error.details?.dispatched !== undefined) { @@ -28,11 +19,20 @@ export async function discloseDispatchAtSideEffectSeam( ok: false, error: { ...response.error, - details: { ...response.error.details, dispatched: seamVerdict() }, + details: { ...response.error.details, dispatched: 'unknown' }, }, }; } catch (error) { - if (error instanceof AppError) throw discloseUnclassifiedDispatch(error, seamVerdict()); + if (error instanceof AppError) throw discloseUnclassifiedDispatch(error, 'unknown'); throw error; } } + +/** A failure response built before any dispatch: the requested operation never reached the device. */ +export function refusedBeforeDispatch(response: DaemonResponse): DaemonResponse { + if (response.ok) return response; + return { + ok: false, + error: { ...response.error, details: { ...response.error.details, dispatched: 'no' } }, + }; +} diff --git a/src/daemon/interaction/internal/interaction-touch-press-admission.ts b/src/daemon/interaction/internal/interaction-touch-press-admission.ts index a2814126e2..e12b01e403 100644 --- a/src/daemon/interaction/internal/interaction-touch-press-admission.ts +++ b/src/daemon/interaction/internal/interaction-touch-press-admission.ts @@ -11,6 +11,7 @@ import { readRefMutationFrame } from '../../ref-frame.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; import type { SessionState } from '../../session-state.ts'; import { refMutationAdmissionResponse } from './interaction-ref-policy.ts'; +import { refusedBeforeDispatch } from './interaction-dispatch-disclosure.ts'; import { settleFlagGuardResponse } from './interaction-flags.ts'; import type { CaptureSnapshotForSession, @@ -60,6 +61,16 @@ type ParsedTargetedTouch = Extract { + const admission = await readTargetedTouchAdmission(params, command); + return 'response' in admission + ? { response: refusedBeforeDispatch(admission.response) } + : admission; +} + +async function readTargetedTouchAdmission( + params: TargetedTouchParams, + command: TargetedTouchCommand, ): Promise<{ response: DaemonResponse } | { admitted: AdmittedTargetedTouch }> { const { req, sessionStore, sessionName } = params; const session = sessionStore.get(sessionName); diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 438369b0ff..ef51e374c3 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -4,7 +4,7 @@ import { type RequestCaptureProof, withCaptureDisclosures } from '../../capture- import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { dispatchFillViaRuntime } from './interaction-touch-fill.ts'; import { dispatchTargetedTouchViaRuntime } from './interaction-touch-press.ts'; -import { discloseDispatchAtSideEffectSeam } from './interaction-dispatch-disclosure.ts'; +import { discloseUnclassifiedInteractionDispatch } from './interaction-dispatch-disclosure.ts'; import { finalizeTouchInteraction } from './interaction-runtime.ts'; import { refSnapshotFlagGuardResponse } from '../../ref-snapshot-flag-policy.ts'; import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../../selector-runtime.ts'; @@ -27,8 +27,7 @@ export async function handleInteractionCommands( ): Promise { const captureProof: RequestCaptureProof = {}; const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; - const response = await discloseDispatchAtSideEffectSeam( - params.sessionStore.get(params.sessionName), + const response = await discloseUnclassifiedInteractionDispatch( async () => await dispatchInteractionCommand(routed), ); return response diff --git a/test/integration/interaction-contract/runtime-selector.contract.test.ts b/test/integration/interaction-contract/runtime-selector.contract.test.ts index 5f8598c0f2..01977e5b46 100644 --- a/test/integration/interaction-contract/runtime-selector.contract.test.ts +++ b/test/integration/interaction-contract/runtime-selector.contract.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { InteractionGuarantee } from '@agent-device/contracts/interaction-guarantees'; import type { Point } from '@agent-device/kernel/snapshot'; +import { INTERACTION_ERROR_REASONS } from '@agent-device/selectors/interaction-error'; import { selector } from '../../../src/commands/interaction/runtime/selector-read-utils.ts'; import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts'; @@ -125,7 +126,14 @@ test(scenario('occlusion'), async () => { await assert.rejects( () => device.interactions.click(selector('label="Save draft"'), { session: 'default' }), - /covered by another visible element/, + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /covered by another visible element/); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, INTERACTION_ERROR_REASONS.targetCovered); + assert.equal(details?.dispatched, 'no'); + return true; + }, ); assert.deepEqual(taps, []); }); From 40276a0fe61f6889c4dab278c8525ebf9bc5718c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 10:58:19 +0200 Subject: [PATCH 03/42] feat(apple-runner): publish runner_busy and runner_main_thread_timeout as details.reason RUNNER_BUSY and MAIN_THREAD_TIMEOUT reached the wire only as details.runnerErrorCode. The one runner-code classifier now also sets details.reason, so a consumer reads a single field; runnerErrorCode stays. --- .../runner-error-classification.test.ts | 12 ++++++++++++ .../platform-apple/src/runner/runner-contract.ts | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index 2919324ad9..d5431f1322 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts @@ -71,6 +71,18 @@ test('only the runner busy refusal earns a resend; a retriable flag alone does n assert.equal(isRetryableRunnerError(commandFailed('boom', { retriable: true })), false); }); +test('the main-thread runner codes publish details.reason beside details.runnerErrorCode', () => { + const busy = classifyRunnerReportedError('RUNNER_BUSY'); + assert.equal(busy.code, 'COMMAND_FAILED'); + assert.equal(busy.details.runnerErrorCode, 'RUNNER_BUSY'); + assert.equal(busy.details.reason, 'runner_busy'); + const timeout = classifyRunnerReportedError('MAIN_THREAD_TIMEOUT'); + assert.equal(timeout.code, 'COMMAND_FAILED'); + assert.equal(timeout.details.runnerErrorCode, 'MAIN_THREAD_TIMEOUT'); + assert.equal(timeout.details.reason, 'runner_main_thread_timeout'); + assert.equal(classifyRunnerReportedError('APP_NOT_RUNNING').details.reason, undefined); +}); + test('retryable requires an AppError with COMMAND_FAILED', () => { assert.equal(isRetryableRunnerError(new Error('fetch failed')), false); assert.equal(isRetryableRunnerError(new AppError('DEVICE_NOT_FOUND', 'fetch failed')), false); diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index f03664a8b2..06c6a9718d 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -262,9 +262,12 @@ export const RUNNER_SCREEN_CAPTURE_REFUSAL_RUNNER_CODES: ReadonlySet = n * the scroll keyboard refusal for a surface the runner declined to swipe under the keys, and the * retriable `APP_NOT_RUNNING` for a read the runner refused rather than launch the session app. */ -const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlyMap = new Map([ - [RUNNER_BUSY_RUNNER_CODE, { retriable: true }], - [MAIN_THREAD_TIMEOUT_RUNNER_CODE, {}], +const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlyMap< + string, + { retriable?: true; reason?: RunnerReportedErrorReason } +> = new Map([ + [RUNNER_BUSY_RUNNER_CODE, { retriable: true, reason: 'runner_busy' }], + [MAIN_THREAD_TIMEOUT_RUNNER_CODE, { reason: 'runner_main_thread_timeout' }], [APP_NOT_RUNNING_RUNNER_CODE, { retriable: true }], [ALERT_NOT_FOUND_RUNNER_CODE, {}], [SCROLL_KEYBOARD_OCCLUDES_SURFACE_RUNNER_CODE, {}], @@ -286,12 +289,19 @@ const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new [MAIN_THREAD_TIMEOUT_RUNNER_CODE, 'unknown'], ]); +/** + * `details.reason` for the runner codes a consumer acts on, so it reads one field instead of + * `details.runnerErrorCode`, which stays for the runner's own vocabulary. + */ +type RunnerReportedErrorReason = 'runner_busy' | 'runner_main_thread_timeout'; + /** Wire code plus the details every path must publish for one runner-reported error code. */ export type RunnerReportedErrorClass = Readonly<{ code: AppError['code']; details: Readonly<{ runnerErrorCode?: string; retriable?: true; + reason?: RunnerReportedErrorReason; dispatched: DispatchDisclosure; }>; }>; From 924b2cbd3d68802ef177f34c703267843243fc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 11:04:20 +0200 Subject: [PATCH 04/42] fix(ios): fall back from a direct selector tap only when it provably did not dispatch isDirectIosSelectorFallbackError matched message text (fetch failed, timed out, runner did not accept connection, invalid runner response), several of which arrive after the tap already ran; the tree path then tapped again. It now keys on details.dispatched === 'no'. The runner discloses no for a failure before the exchange, a pre-send recovery verdict (connect refused, readiness preflight, RUNNER_BUSY), a failed restart, a spent recycle budget, and its selector refusals (ELEMENT_NOT_FOUND, ELEMENT_OFFSCREEN, AMBIGUOUS_MATCH). --- contracts/fixtures/dispatch-disclosure.json | 58 +++++++++ .../src/dispatch-disclosure.fixtures.ts | 6 + .../runner-dispatch-disclosure.test.ts | 3 + ...nner-lifecycle-dispatch-disclosure.test.ts | 117 ++++++++++++++++++ .../src/runner/runner-contract.ts | 6 +- .../src/runner/runner-error-classification.ts | 13 ++ .../src/runner/runner-lifecycle.ts | 30 ++++- .../src/runner/runner-recycle-ledger.ts | 31 ++--- .../__tests__/direct-ios-selector.test.ts | 102 ++++++++------- src/daemon/direct-ios-selector.ts | 40 +++--- .../interaction-touch-direct-ios.test.ts | 66 ++++++++++ .../internal/interaction-touch-direct-ios.ts | 6 +- 12 files changed, 389 insertions(+), 89 deletions(-) create mode 100644 packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 9fdee7768a..7426a5fad9 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -27,6 +27,22 @@ "trigger": "an unclassified failure after the session object was replaced mid-request", "dispatched": "unknown" }, + { + "id": "maestro-direct.fallback.pre-send-refusal", + "phase": "after-seam", + "producer": "daemon", + "trigger": "the direct iOS selector tap failed with a runner refusal disclosed no (connect refused before send); the tree path taps instead", + "dispatched": "no", + "fallsBack": true + }, + { + "id": "maestro-direct.fallback.lost-reply", + "phase": "after-seam", + "producer": "daemon", + "trigger": "the direct iOS selector tap lost its reply and the status probe failed (unknown); the tap may have landed, so the tree path must not tap again", + "dispatched": "unknown", + "fallsBack": false + }, { "id": "post-action-guard.android-press-left-app", "phase": "after-seam", @@ -90,6 +106,27 @@ "trigger": "structured runner reply with code APP_SCREEN_CAPTURE_UNRENDERABLE: a refusal that ran nothing", "dispatched": "no" }, + { + "id": "ios-runner.reply.ELEMENT_NOT_FOUND", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code ELEMENT_NOT_FOUND: a selector refusal before any gesture", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.ELEMENT_OFFSCREEN", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code ELEMENT_OFFSCREEN: a selector refusal before any gesture", + "dispatched": "no" + }, + { + "id": "ios-runner.reply.AMBIGUOUS_MATCH", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "structured runner reply with code AMBIGUOUS_MATCH: a selector refusal before any gesture", + "dispatched": "no" + }, { "id": "ios-runner.reply.MAIN_THREAD_TIMEOUT", "phase": "after-seam", @@ -104,6 +141,27 @@ "trigger": "structured runner reply with any other code, e.g. XCTEST_RECORDED_FAILURE", "dispatched": "yes" }, + { + "id": "ios-runner.pre-send.session-start-failed", + "phase": "before-seam", + "producer": "ios-runner", + "trigger": "the runner session could not start, so the command never reached the exchange", + "dispatched": "no" + }, + { + "id": "ios-runner.pre-send.connect-refused-restart-failed", + "phase": "before-seam", + "producer": "ios-runner", + "trigger": "the runner refused the connection before the command was sent, and the restart that would replay it failed", + "dispatched": "no" + }, + { + "id": "ios-runner.pre-send.readiness-preflight-after-restart", + "phase": "before-seam", + "producer": "ios-runner", + "trigger": "the readiness preflight gave up before the command was written, again after the restart", + "dispatched": "no" + }, { "id": "ios-runner.status.failed", "phase": "after-seam", diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index e0db69cb94..a5ddad39f7 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -22,6 +22,8 @@ export type DispatchDisclosureRow = { producer: DispatchDisclosureProducer; trigger: string; dispatched: DispatchDisclosure; + /** Direct iOS selector tap rows: whether the failure delegates to the tree path, which taps again. */ + fallsBack?: boolean; /** A branch that ships this row's producer; the row is not owned here until it merges. */ implementedBy?: string; }; @@ -43,10 +45,14 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'post-action-guard.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', 'ios-runner.': 'packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts', + 'ios-runner.pre-send.': + 'packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts', 'android-adb.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', 'android-helper.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', 'android-helper.gesture-session.': 'packages/platform-android/src/__tests__/touch-helper-session.test.ts', + 'maestro-direct.': + 'src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts', }; export function dispatchDisclosureDriverOwner(rowId: string): string | undefined { diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index aeac69054e..827fd27d86 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -111,6 +111,9 @@ const DRIVERS: Record Promise> = { 'ios-runner.reply.APP_SCREEN_UNRESOLVED': () => replyFailure('APP_SCREEN_UNRESOLVED'), 'ios-runner.reply.APP_SCREEN_CAPTURE_UNRENDERABLE': () => replyFailure('APP_SCREEN_CAPTURE_UNRENDERABLE'), + 'ios-runner.reply.ELEMENT_NOT_FOUND': () => replyFailure('ELEMENT_NOT_FOUND'), + 'ios-runner.reply.ELEMENT_OFFSCREEN': () => replyFailure('ELEMENT_OFFSCREEN'), + 'ios-runner.reply.AMBIGUOUS_MATCH': () => replyFailure('AMBIGUOUS_MATCH'), 'ios-runner.reply.MAIN_THREAD_TIMEOUT': () => replyFailure('MAIN_THREAD_TIMEOUT'), 'ios-runner.reply.executed-failure': () => replyFailure('XCTEST_RECORDED_FAILURE'), 'ios-runner.status.failed': () => diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts new file mode 100644 index 0000000000..fabede3b1a --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { beforeEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { + createTestRequestCancellation, + makeRunnerSession, + runnerConnectFailure, +} from './runner-session-fixtures.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; + +// contracts/fixtures/dispatch-disclosure.json, ios-runner pre-send rows: each row drives +// runAppleRunnerCommand through the real lifecycle and restart path with only the session start +// and the exchange mocked, and asserts the failure the caller receives. + +const { + mockEnsureRunnerSession, + mockExecuteRunnerCommandWithSession, + mockInvalidateRunnerSession, +} = vi.hoisted(() => ({ + mockEnsureRunnerSession: vi.fn(), + mockExecuteRunnerCommandWithSession: vi.fn(), + mockInvalidateRunnerSession: vi.fn(), +})); + +vi.mock('../runner-session.ts', async () => { + const actual = + await vi.importActual('../runner-session.ts'); + return { + ...actual, + ensureRunnerSession: mockEnsureRunnerSession, + executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession, + readRunnerSessionLiveness: vi.fn(() => null), + invalidateRunnerSession: mockInvalidateRunnerSession, + }; +}); + +import { runAppleRunnerCommand } from '../runner-client.ts'; +import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; + +const requestCancellation = createTestRequestCancellation(); + +beforeEach(() => { + mockEnsureRunnerSession.mockReset(); + mockExecuteRunnerCommandWithSession.mockReset(); + mockInvalidateRunnerSession.mockReset(); + resetRunnerRecycleLedgerForTests(); + requestCancellation.reset(); + appleRunnerTestHost.update({ + emitDiagnostic: vi.fn(), + isRequestCanceled: requestCancellation.isRequestCanceled, + getRequestSignal: () => undefined, + }); +}); + +async function tap(): Promise { + return await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); +} + +const readinessPreflightFailure = (): AppError => + new AppError('COMMAND_FAILED', 'Runner readiness refused', { + runnerReadinessPreflightFailed: true, + }); + +const DRIVERS: Record Promise> = { + 'ios-runner.pre-send.session-start-failed': async () => { + mockEnsureRunnerSession.mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed'), + ); + return await tap(); + }, + 'ios-runner.pre-send.connect-refused-restart-failed': async () => { + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); + mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused'), + ); + return await tap(); + }, + 'ios-runner.pre-send.readiness-preflight-after-restart': async () => { + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101 })); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(readinessPreflightFailure()) + .mockRejectedValueOnce(readinessPreflightFailure()); + return await tap(); + }, +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every ios-runner pre-send dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 06c6a9718d..538a2b8903 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -276,10 +276,14 @@ const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlyMap< /** * Runner codes whose reply proves something other than "the command executed and failed". The - * refusals answer before the command runs; `MAIN_THREAD_TIMEOUT` abandons work that may still land. + * refusals answer before the command runs, the selector refusals included: the runner resolves the + * element and refuses before any gesture. `MAIN_THREAD_TIMEOUT` abandons work that may still land. * Every other structured reply comes from a command the runner executed. */ const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new Map([ + ['ELEMENT_NOT_FOUND', 'no'], + ['ELEMENT_OFFSCREEN', 'no'], + ['AMBIGUOUS_MATCH', 'no'], [RUNNER_BUSY_RUNNER_CODE, 'no'], [RUNNER_WEDGED_RUNNER_CODE, 'no'], [APP_NOT_RUNNING_RUNNER_CODE, 'no'], diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index fe5faad8c1..06cf3e66d1 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -618,6 +618,19 @@ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; } +/** + * The recovery table places this failure before the runner received the command: a connect-shaped + * failure or readiness-preflight give-up (both replayed after a restart), or a `RUNNER_BUSY` + * refusal. The command never ran, so its failure discloses `dispatched: no`. + */ +export function isRunnerPreSendRefusal(error: unknown): boolean { + return ( + shouldRestartRunnerBeforeCommandSend(error) || + shouldRestartRunnerAfterReadinessPreflight(error) || + isRunnerBusyError(error) + ); +} + /** * The one classifier for "the runner did not reach the point of serving a command" (#2680). Every * path that stops the runner before it answers a request routes its failure through here, so the diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index fbde46f126..a0bc612a19 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -2,6 +2,8 @@ import { AppError, asAppError, createRequestCanceledError, + discloseDispatch, + discloseUnclassifiedDispatch, isRequestCanceledError, } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -27,6 +29,7 @@ import { } from './runner-contract.ts'; import { isRetryableRunnerError, + isRunnerPreSendRefusal, isStructuredRunnerFailure, shouldRebuildCachedRunnerArtifact, shouldRestartRunnerAfterReadinessPreflight, @@ -260,11 +263,32 @@ function shouldRetryPrepareRunnerHealthFailure(error: AppError): boolean { return isRetryableRunnerError(error) || shouldRetryRunnerConnectError(error); } -// fallow-ignore-next-line complexity +/** + * Runs one runner command and discloses `dispatched: no` on a failure raised before the command + * reached the exchange, or classified as a pre-send refusal by the recovery table. + */ export async function executeRunnerCommand( device: DeviceInfo, command: RunnerCommand, options: AppleRunnerCommandOptions, +): Promise> { + const exchange = { entered: false }; + try { + return await executeRunnerCommandAttempt(device, command, options, exchange); + } catch (error) { + if (!(error instanceof AppError)) throw error; + if (!exchange.entered) throw discloseDispatch(error, 'no'); + if (isRunnerPreSendRefusal(error)) throw discloseUnclassifiedDispatch(error, 'no'); + throw error; + } +} + +// fallow-ignore-next-line complexity +async function executeRunnerCommandAttempt( + device: DeviceInfo, + command: RunnerCommand, + options: AppleRunnerCommandOptions, + exchange: { entered: boolean }, ): Promise> { assertRunnerRequestActive(options.requestId); const signal = resolveRunnerRequestSignal(options); @@ -299,6 +323,7 @@ export async function executeRunnerCommand( const { readRunnerStartupTimeoutMs } = await import('./runner-exchange.ts'); timeoutMs = readRunnerStartupTimeoutMs(session); } + exchange.entered = true; return await executeRunnerCommandWithSession( device, session, @@ -392,7 +417,8 @@ async function restartSessionAndRunCommand(params: { ...options, cleanStaleBundles: true, }).catch((error: unknown) => { - throw markRunnerRestartError(error, params); + const restartError = markRunnerRestartError(error, params); + throw restartError instanceof AppError ? discloseDispatch(restartError, 'no') : restartError; }); commitRunnerRecycle(recycleKey); try { diff --git a/packages/platform-apple/src/runner/runner-recycle-ledger.ts b/packages/platform-apple/src/runner/runner-recycle-ledger.ts index bcdffaecd3..149e681ea3 100644 --- a/packages/platform-apple/src/runner/runner-recycle-ledger.ts +++ b/packages/platform-apple/src/runner/runner-recycle-ledger.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import { emitDiagnostic } from './host.ts'; import type { RunnerCommand } from './runner-contract.ts'; @@ -85,19 +85,22 @@ export function buildRunnerRecycleBudgetExhaustedError( maxRecycles: MAX_RUNNER_RECYCLES_PER_REQUEST, }, }); - return new AppError( - 'COMMAND_FAILED', - `iOS runner was already restarted during this request and "${command.command}" still failed, so agent-device stopped instead of paying for another runner boot.`, - { - command: command.command, - commandId: command.commandId, - recovery: 'runner_recycle_budget_exhausted', - // This path only knows that a restart was already spent, never why the - // runner failed. Naming the heavy-screen case as the cause sent people - // to change screens when the runner had in fact failed to install. - hint: 'Check the runner log for the underlying failure before retrying — a provisioning or code-signing error there means the runner cannot install on this device, and no retry will help. If the runner is healthy, the current screen is likely too heavy or animating for accessibility capture: the app session is preserved, so run `screenshot` for visual truth and interact by coordinates, or navigate to another screen.', - logPath: options.logPath, - }, + return discloseDispatch( + new AppError( + 'COMMAND_FAILED', + `iOS runner was already restarted during this request and "${command.command}" still failed, so agent-device stopped instead of paying for another runner boot.`, + { + command: command.command, + commandId: command.commandId, + recovery: 'runner_recycle_budget_exhausted', + // This path only knows that a restart was already spent, never why the + // runner failed. Naming the heavy-screen case as the cause sent people + // to change screens when the runner had in fact failed to install. + hint: 'Check the runner log for the underlying failure before retrying — a provisioning or code-signing error there means the runner cannot install on this device, and no retry will help. If the runner is healthy, the current screen is likely too heavy or animating for accessibility capture: the app session is preserved, so run `screenshot` for visual truth and interact by coordinates, or navigate to another screen.', + logPath: options.logPath, + }, + ), + 'no', ); } diff --git a/src/daemon/__tests__/direct-ios-selector.test.ts b/src/daemon/__tests__/direct-ios-selector.test.ts index 8bf0d3e5e2..f2abdfa116 100644 --- a/src/daemon/__tests__/direct-ios-selector.test.ts +++ b/src/daemon/__tests__/direct-ios-selector.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import type { SessionState } from '../session-state.ts'; import { @@ -22,65 +22,71 @@ function makeSession( }; } -test('runner ELEMENT_OFFSCREEN delegates normally but stays typed for Maestro replay', () => { - const error = new AppError('ELEMENT_OFFSCREEN', 'element resolved off-screen at (-161, 265)'); - assert.equal(isDirectIosSelectorFallbackError(error), true); - assert.equal(isDirectIosSelectorFallbackError(error, { allowElementNotFound: false }), true); - assert.equal(isDirectIosSelectorFallbackError(error, { delegateSemanticFailures: true }), true); - assert.equal(isDirectIosSelectorFallbackError(error, { delegateSemanticFailures: false }), false); -}); +function refusal(code: AppError['code'], message: string): AppError { + return new AppError(code, message, { dispatched: 'no' }); +} -test('runner ELEMENT_NOT_FOUND falls back for query callers that allow it', () => { - const error = new AppError('ELEMENT_NOT_FOUND', 'element not found'); - assert.equal(isDirectIosSelectorFallbackError(error), false); - assert.equal(isDirectIosSelectorFallbackError(error, { allowElementNotFound: true }), true); +test('runner selector refusals delegate for interaction dispatches (ADR 0011)', () => { + for (const code of ['ELEMENT_NOT_FOUND', 'ELEMENT_OFFSCREEN', 'AMBIGUOUS_MATCH'] as const) { + assert.equal( + isDirectIosSelectorFallbackError(refusal(code, code), { delegateSemanticFailures: true }), + true, + code, + ); + } }); -test('semantic failures delegate to the runtime path for interaction dispatches (ADR 0011)', () => { - const notFound = new AppError('ELEMENT_NOT_FOUND', 'element not found'); - const ambiguous = new AppError('AMBIGUOUS_MATCH', 'multiple'); - assert.equal( - isDirectIosSelectorFallbackError(notFound, { delegateSemanticFailures: true }), - true, - ); - assert.equal( - isDirectIosSelectorFallbackError(ambiguous, { delegateSemanticFailures: true }), - true, - ); +test('maestro replay dispatches preserve the runner selector refusal shapes (no fallback)', () => { + for (const code of ['ELEMENT_NOT_FOUND', 'ELEMENT_OFFSCREEN', 'AMBIGUOUS_MATCH'] as const) { + assert.equal( + isDirectIosSelectorFallbackError(refusal(code, code), { delegateSemanticFailures: false }), + false, + code, + ); + } }); -test('maestro replay dispatches preserve the runner semantic error shapes (no fallback)', () => { - const notFound = new AppError('ELEMENT_NOT_FOUND', 'element not found'); - const ambiguous = new AppError('AMBIGUOUS_MATCH', 'multiple'); - assert.equal( - isDirectIosSelectorFallbackError(notFound, { delegateSemanticFailures: false }), - false, - ); +test('a pre-send COMMAND_FAILED falls back; the message text never decides', () => { + const options = { delegateSemanticFailures: false }; assert.equal( - isDirectIosSelectorFallbackError(ambiguous, { delegateSemanticFailures: false }), - false, + isDirectIosSelectorFallbackError( + refusal('COMMAND_FAILED', 'element covered by overlay'), + options, + ), + true, ); + for (const message of [ + 'fetch failed', + 'Runner command deadline exceeded: timed out', + 'Runner did not accept connection', + 'Invalid runner response', + ]) { + assert.equal( + isDirectIosSelectorFallbackError(new AppError('COMMAND_FAILED', message), options), + false, + message, + ); + } }); -test('AMBIGUOUS_MATCH does not fall back on the query path (allowElementNotFound callers)', () => { - const ambiguous = new AppError('AMBIGUOUS_MATCH', 'multiple'); - assert.equal(isDirectIosSelectorFallbackError(ambiguous), false); - assert.equal(isDirectIosSelectorFallbackError(ambiguous, { allowElementNotFound: true }), false); +test('a failure that may have tapped never falls back', () => { + for (const dispatched of ['unknown', 'yes'] as const) { + for (const code of ['COMMAND_FAILED', 'ELEMENT_NOT_FOUND'] as const) { + assert.equal( + isDirectIosSelectorFallbackError(new AppError(code, 'failed', { dispatched }), { + delegateSemanticFailures: true, + }), + false, + `${code} ${dispatched}`, + ); + } + } }); -test('transport-level COMMAND_FAILED errors fall back, semantic ones do not', () => { - assert.equal( - isDirectIosSelectorFallbackError(new AppError('COMMAND_FAILED', 'fetch failed')), - true, - ); - assert.equal( - isDirectIosSelectorFallbackError( - new AppError('COMMAND_FAILED', 'Runner command deadline exceeded: timed out'), - ), - true, - ); +test('a canceled request never falls back', () => { + const canceled = createRequestCanceledError({ dispatched: 'no' }); assert.equal( - isDirectIosSelectorFallbackError(new AppError('COMMAND_FAILED', 'element covered by overlay')), + isDirectIosSelectorFallbackError(canceled, { delegateSemanticFailures: true }), false, ); }); diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 8eb0688635..c070312fe8 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -4,7 +4,7 @@ import { isActiveProviderDevice } from './provider-device-admission.ts'; import { isPostGestureStabilizationPending } from './deferred-interaction-outcome.ts'; import type { SessionState } from './session-state.ts'; import { readSimpleSelectorTarget } from '@agent-device/selectors'; -import { asAppError } from '@agent-device/kernel/errors'; +import { asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { ElementSelectorTapOptions } from '@agent-device/contracts/interactor-types'; import { queryAppleRuntimeSelector } from '../platform-runtime-apple-resources.ts'; import type { AppleRunnerRequestOptions } from './apple-runner-options.ts'; @@ -79,28 +79,26 @@ function readDirectIosSelectorNode(data: Record): SnapshotNode return node as SnapshotNode; } +/** The runner's selector refusals: it resolved the selector and refused before any gesture. */ +const RUNNER_SELECTOR_REFUSAL_CODES: ReadonlySet = new Set([ + 'ELEMENT_NOT_FOUND', + 'ELEMENT_OFFSCREEN', + 'AMBIGUOUS_MATCH', +]); + +/** + * Whether a failed direct iOS selector tap may delegate to the tree path, which taps again. Only a + * failure disclosed `dispatched: no` may: a connect refusal, a pre-send restart or readiness + * verdict, `RUNNER_BUSY`, or a runner selector refusal. A failure that is `unknown` or `yes` may + * already have tapped. Selector refusals delegate only when `delegateSemanticFailures` is set; + * Maestro replay keeps their runner-native shapes. + */ export function isDirectIosSelectorFallbackError( error: unknown, - options: { - allowElementNotFound?: boolean; - delegateSemanticFailures?: boolean; - } = {}, + options: { delegateSemanticFailures: boolean }, ): boolean { const appError = asAppError(error); - if (appError.code === 'ELEMENT_NOT_FOUND') { - return options.delegateSemanticFailures === true || options.allowElementNotFound === true; - } - if (appError.code === 'AMBIGUOUS_MATCH') return options.delegateSemanticFailures === true; - if (appError.code === 'ELEMENT_OFFSCREEN') { - return options.delegateSemanticFailures !== false; - } - if (appError.code !== 'COMMAND_FAILED') return false; - const message = appError.message.toLowerCase(); - return ( - message.includes('fetch failed') || - message.includes('timed out') || - message.includes('timeout') || - message.includes('runner did not accept connection') || - message.includes('invalid runner response') - ); + if (appError.details?.dispatched !== 'no' || isRequestCanceledError(appError)) return false; + if (RUNNER_SELECTOR_REFUSAL_CODES.has(appError.code)) return options.delegateSemanticFailures; + return appError.code === 'COMMAND_FAILED'; } diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts index c5d5d554b7..36e9f8d9d0 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts @@ -1,4 +1,11 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { beforeEach, expect, test, vi } from 'vitest'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; @@ -6,14 +13,17 @@ import { handleInteractionCommands } from '../../index.ts'; import { getRuntimeBindings, mockTapElementSelector, + mockTapPoint, resetGetRuntimeFixture, } from '../../../__tests__/interaction-get-runtime-fixture.ts'; import { contextFromFlags, makeStaleRefSession, + makeTwoButtonNodes, runInteraction, } from './interaction-touch-fixtures.ts'; import { refFrameState } from '../../../ref-frame.ts'; +import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); @@ -74,3 +84,59 @@ test('Maestro selector click crosses the ADR 0014 fused seam and expires the ref expect(mockTapElementSelector).toHaveBeenCalledOnce(); expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); + +// contracts/fixtures/dispatch-disclosure.json, maestro-direct rows: a Maestro selector click through +// the daemon handler with the runner's selector tap mocked to fail; the row decides whether the +// tree path may tap again. + +const DIRECT_TAP_FAILURES: Record AppError> = { + 'maestro-direct.fallback.pre-send-refusal': () => + new AppError('COMMAND_FAILED', 'Runner did not accept connection', { + runnerConnectFailureReason: 'runner_connect_refused', + dispatched: 'no', + }), + 'maestro-direct.fallback.lost-reply': () => + new AppError('COMMAND_FAILED', 'fetch failed', { + recovery: 'status_probe_failed', + dispatched: 'unknown', + }), +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every maestro-direct dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DIRECT_TAP_FAILURES)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const failure = DIRECT_TAP_FAILURES[row.id]; + assert.ok(failure, `no driver for ${row.id}`); + assert.equal(typeof row.fallsBack, 'boolean', `${row.id} must state fallsBack`); + const sessionStore = makeSessionStore(); + const sessionName = `maestro-direct-${row.id}`; + sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + mockTapElementSelector.mockRejectedValueOnce(failure()); + vi.mocked(captureSnapshotWithInteractor).mockResolvedValue({ + nodes: makeTwoButtonNodes(), + backend: 'xctest', + producer: 'apple-runner', + }); + + const response = await runInteraction(sessionStore, sessionName, 'click', ['label=Continue'], { + maestro: { allowNonHittableCoordinateFallback: true }, + }); + + expect(mockTapElementSelector).toHaveBeenCalledOnce(); + expect(mockTapPoint.mock.calls.length).toBe(row.fallsBack ? 1 : 0); + if (row.fallsBack) { + expect(response?.ok).toBe(true); + return; + } + expect(response?.ok).toBe(false); + if (response?.ok === false) expect(response.error.details?.dispatched).toBe(row.dispatched); + }); +} diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index 74f926da75..90a00fa6c9 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -94,9 +94,9 @@ export async function dispatchDirectIosSelectorTap( actionStartedAt, }); if (corroboratedResponse) return corroboratedResponse; - // ADR 0011 delegation-on-error: semantic runner failures fall back to the - // tree-based runtime path — except for Maestro replay dispatches, whose - // runner-native error shapes must be preserved. + // ADR 0011 delegation-on-error: only a failure disclosed `dispatched: no` + // falls back to the tree path, which taps again; Maestro replay keeps the + // runner's selector refusal shapes. const fallback = isDirectIosSelectorFallbackError(error, { delegateSemanticFailures: selector.allowNonHittableCoordinateFallback !== true, }); From 25620097a6b7ff73ec0bf6fb530702b938338705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 11:06:22 +0200 Subject: [PATCH 05/42] fix(errors): disclose dispatched yes where a producer ran the action on purpose scroll_no_progress is raised after the scroll gesture ran, and an Android fill whose verification fails after the last clear-and-retype pass typed the text; both now say yes instead of falling to the daemon's unknown. --- contracts/fixtures/dispatch-disclosure.json | 14 +++++++ .../src/dispatch-disclosure.fixtures.ts | 1 + .../src/__tests__/dispatch-disclosure.test.ts | 11 ++++++ .../platform-android/src/fill-verification.ts | 13 ++++--- src/daemon/__tests__/scroll-movement.test.ts | 37 +++++++++++++++++++ src/daemon/scroll-movement.ts | 5 ++- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 7426a5fad9..af943f38f6 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -27,6 +27,13 @@ "trigger": "an unclassified failure after the session object was replaced mid-request", "dispatched": "unknown" }, + { + "id": "daemon.scroll-no-progress", + "phase": "after-seam", + "producer": "daemon", + "trigger": "scroll_no_progress: the scroll gesture ran and the container, still hiding content, never shifted", + "dispatched": "yes" + }, { "id": "maestro-direct.fallback.pre-send-refusal", "phase": "after-seam", @@ -239,6 +246,13 @@ "trigger": "adb input text failed after at least one chunk was typed (details.dispatchedSteps)", "dispatched": "unknown" }, + { + "id": "android-adb.fill.unverified", + "phase": "after-seam", + "producer": "android-adb", + "trigger": "fill typed the text and its verification still failed after the last clear-and-retype pass. A throw from inside the second pass keeps that pass's adb input verdict (unknown with dispatchedSteps), though the first pass already typed", + "dispatched": "yes" + }, { "id": "android-helper.gesture.reported-failure", "phase": "after-seam", diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index a5ddad39f7..1b4803c46d 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -42,6 +42,7 @@ export const DISPATCH_DISCLOSURE_TABLE_PATH = path.join( */ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> = { 'daemon.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', + 'daemon.scroll-no-progress': 'src/daemon/__tests__/scroll-movement.test.ts', 'post-action-guard.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', 'ios-runner.': 'packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts', diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index 1cb0e2d43c..dd15896a4e 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -9,6 +9,7 @@ import { dispatchDisclosureRowsOwnedBy, } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { withAndroidAdbProvider, type AndroidAdbExecutor } from '../adb-executor.ts'; +import { completeAndroidFillVerification } from '../fill-verification.ts'; import { pressAndroid } from '../input-actions.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; import { typeAndroid } from '../text-input.ts'; @@ -98,6 +99,16 @@ const DRIVERS: Record Promise; dispatchedSteps?: drive: typeFailingOnSecondChunk, dispatchedSteps: 1, }, + 'android-adb.fill.unverified': { + drive: async () => + completeAndroidFillVerification('filed the expense', null, { + ok: false, + actual: 'filed the', + reason: 'text_mismatch', + targetInput: null, + actualInput: null, + }), + }, 'android-helper.gesture.reported-failure': { drive: () => oneShotGesture(async () => ({ exitCode: 0, stdout: HELPER_REPORTED_FAILURE, stderr: '' })), diff --git a/packages/platform-android/src/fill-verification.ts b/packages/platform-android/src/fill-verification.ts index bc55aa7c56..cd4072363d 100644 --- a/packages/platform-android/src/fill-verification.ts +++ b/packages/platform-android/src/fill-verification.ts @@ -1,5 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { containsPoint } from '@agent-device/kernel/rect'; import { @@ -141,10 +141,13 @@ export function completeAndroidFillVerification( ? buildAndroidFillUnconfirmedVerification(expected, beforeTarget, verification) : null; if (unconfirmed) return unconfirmed; - throw new AppError( - 'COMMAND_FAILED', - androidFillFailureMessage(verification), - androidFillFailureDetails(expected, verification), + throw discloseDispatch( + new AppError( + 'COMMAND_FAILED', + androidFillFailureMessage(verification), + androidFillFailureDetails(expected, verification), + ), + verification ? 'yes' : 'unknown', ); } diff --git a/src/daemon/__tests__/scroll-movement.test.ts b/src/daemon/__tests__/scroll-movement.test.ts index 5eb444ac58..bb5c7c7d3d 100644 --- a/src/daemon/__tests__/scroll-movement.test.ts +++ b/src/daemon/__tests__/scroll-movement.test.ts @@ -1,5 +1,11 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { test, vi } from 'vitest'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; import { AppError } from '@agent-device/kernel/errors'; @@ -346,6 +352,37 @@ test('a surface that never shifted while the container still hides content refus assert.equal(spy.calls(), 2); }); +// contracts/fixtures/dispatch-disclosure.json, the scroll-no-progress row: the gesture ran, so the +// refusal is not a pre-dispatch one. +const DISPATCH_DISCLOSURE_ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); +const DISPATCH_DISCLOSURE_DRIVERS: Record Promise> = { + 'daemon.scroll-no-progress': async () => + await observe({ baseline: baselineOf(screen(0)), screens: [screen(0), screen(0)] }).observation, +}; + +test('every scroll-movement dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows( + DISPATCH_DISCLOSURE_ROWS, + Object.keys(DISPATCH_DISCLOSURE_DRIVERS), + ); +}); + +for (const row of DISPATCH_DISCLOSURE_ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DISPATCH_DISCLOSURE_DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'scroll_no_progress'); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} + /** * The refusal above names a raw drag because it knows where the swipe ran. An owner that reports no * coordinates (a tvOS scroll is a remote keypress) must not be told to swipe where it cannot. diff --git a/src/daemon/scroll-movement.ts b/src/daemon/scroll-movement.ts index cd70a3d3d2..2e3af24444 100644 --- a/src/daemon/scroll-movement.ts +++ b/src/daemon/scroll-movement.ts @@ -11,7 +11,7 @@ import { type ScrollEdge, } from '@agent-device/capture-kit/scroll-edge-state'; import { containsPoint } from '@agent-device/kernel/rect'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { Point, Rect, SnapshotState } from '@agent-device/kernel/snapshot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { @@ -576,7 +576,7 @@ function scrollNoProgressError( swipe: ScrollSwipeEvidence, containerRect: Rect, ): AppError { - return new AppError( + const error = new AppError( 'COMMAND_FAILED', `scroll ${direction} moved nothing: the container still reports hidden content ${ edge === 'bottom' ? 'below' : 'above' @@ -593,6 +593,7 @@ function scrollNoProgressError( }), }, ); + return discloseDispatch(error, 'yes'); } /** From 9eca447bb07d1d06e2c8da39e7dc57324a8fb700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 11:18:51 +0200 Subject: [PATCH 06/42] fix(errors): claim no only for a connect that provably wrote nothing, and for read-only commands A runner connect failure classified for replay-after-restart said no even when an attempt had already posted the command (a fetch deadline, a simctl curl that exited after the POST). The connect loop now records whether every attempt was refused before writing (ECONNREFUSED, a usbmux socket that never opened, curl exit 7, a pre-attempt deadline), and only that failure is a pre-send refusal; the restart path reports unknown when the first attempt may have written. The daemon fallback stamps no for a command the registry declares read-only (recordingEffect observes-app). --- contracts/fixtures/dispatch-disclosure.json | 18 ++++- .../src/dispatch-disclosure.fixtures.ts | 2 + ...nner-lifecycle-dispatch-disclosure.test.ts | 81 ++++++++++++++----- .../src/runner/runner-error-classification.ts | 29 ++++++- .../src/runner/runner-lifecycle.ts | 34 ++++++-- .../src/runner/runner-startup-transport.ts | 77 ++++++++++++++---- .../src/runner/runner-usbmux.ts | 5 +- .../interaction-dispatch-disclosure.test.ts | 13 ++- .../interaction-dispatch-disclosure.ts | 25 ++++-- .../interaction/internal/interaction.ts | 1 + 10 files changed, 230 insertions(+), 55 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index af943f38f6..d546bba690 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -27,6 +27,13 @@ "trigger": "an unclassified failure after the session object was replaced mid-request", "dispatched": "unknown" }, + { + "id": "daemon.read-only-command", + "phase": "after-seam", + "producer": "daemon", + "trigger": "an unclassified failure of a command the registry declares read-only (recordingEffect observes-app), e.g. get", + "dispatched": "no" + }, { "id": "daemon.scroll-no-progress", "phase": "after-seam", @@ -156,12 +163,19 @@ "dispatched": "no" }, { - "id": "ios-runner.pre-send.connect-refused-restart-failed", + "id": "ios-runner.pre-send.connect-refused-before-write", "phase": "before-seam", "producer": "ios-runner", - "trigger": "the runner refused the connection before the command was sent, and the restart that would replay it failed", + "trigger": "every connect attempt was refused before a byte was written (ECONNREFUSED, simctl curl exit 7), and the restart that would replay the command failed", "dispatched": "no" }, + { + "id": "ios-runner.transport.written-then-lost", + "phase": "after-seam", + "producer": "ios-runner", + "trigger": "a connect attempt posted the command and then timed out (fetch deadline, simctl curl exit 28); the failure keeps the runner_connect_refused restart verdict, and the restart that replays it failed", + "dispatched": "unknown" + }, { "id": "ios-runner.pre-send.readiness-preflight-after-restart", "phase": "before-seam", diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index 1b4803c46d..d181213eba 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -48,6 +48,8 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'ios-runner.': 'packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts', 'ios-runner.pre-send.': 'packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts', + 'ios-runner.transport.': + 'packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts', 'android-adb.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', 'android-helper.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', 'android-helper.gesture-session.': diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index fabede3b1a..b965cf80eb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { assertDispatchDisclosureDriversMatchRows, @@ -8,16 +8,13 @@ import { dispatchDisclosureRowsOwnedBy, } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { - createTestRequestCancellation, - makeRunnerSession, - runnerConnectFailure, -} from './runner-session-fixtures.ts'; +import { createTestRequestCancellation, makeRunnerSession } from './runner-session-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; -// contracts/fixtures/dispatch-disclosure.json, ios-runner pre-send rows: each row drives -// runAppleRunnerCommand through the real lifecycle and restart path with only the session start -// and the exchange mocked, and asserts the failure the caller receives. +// contracts/fixtures/dispatch-disclosure.json, ios-runner pre-send and transport rows: each row +// drives runAppleRunnerCommand through the real lifecycle and restart path with the session start +// mocked; the connect rows run the real connect loop (waitForRunner) over a stubbed fetch and simctl +// curl, and assert the failure the caller receives. const { mockEnsureRunnerSession, @@ -43,6 +40,7 @@ vi.mock('../runner-session.ts', async () => { import { runAppleRunnerCommand } from '../runner-client.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; +import { waitForRunner } from '../runner-startup-transport.ts'; const requestCancellation = createTestRequestCancellation(); @@ -59,6 +57,10 @@ beforeEach(() => { }); }); +afterEach(() => { + vi.unstubAllGlobals(); +}); + async function tap(): Promise { return await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); } @@ -68,6 +70,43 @@ const readinessPreflightFailure = (): AppError => runnerReadinessPreflightFailed: true, }); +/** + * The first attempt runs the real connect loop against a simulator whose every fetch fails the + * same way and whose simctl curl fallback exits with `curlExitCode`; the restart it earns fails. + */ +async function connectLoopThenFailedRestart(transport: { + fetchFailure: () => Error; + curlExitCode: number; +}): Promise { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw transport.fetchFailure(); + }), + ); + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ + exitCode: transport.curlExitCode, + stdout: '', + stderr: `curl exited ${transport.curlExitCode}`, + })), + }); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); + mockExecuteRunnerCommandWithSession.mockImplementationOnce( + async (device, session, command) => + await waitForRunner(device, session.port, command, undefined, 400), + ); + try { + return await tap(); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerRestartReason, 'runner_connect_failed_before_command_send'); + throw error; + } +} + const DRIVERS: Record Promise> = { 'ios-runner.pre-send.session-start-failed': async () => { mockEnsureRunnerSession.mockRejectedValueOnce( @@ -75,15 +114,21 @@ const DRIVERS: Record Promise> = { ); return await tap(); }, - 'ios-runner.pre-send.connect-refused-restart-failed': async () => { - mockEnsureRunnerSession - .mockResolvedValueOnce(makeRunnerSession()) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); - mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused'), - ); - return await tap(); - }, + 'ios-runner.pre-send.connect-refused-before-write': () => + connectLoopThenFailedRestart({ + fetchFailure: () => + new TypeError('fetch failed', { + cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8100'), { + code: 'ECONNREFUSED', + }), + }), + curlExitCode: 7, + }), + 'ios-runner.transport.written-then-lost': () => + connectLoopThenFailedRestart({ + fetchFailure: () => new AppError('COMMAND_FAILED', 'Runner command deadline exceeded'), + curlExitCode: 28, + }), 'ios-runner.pre-send.readiness-preflight-after-restart': async () => { mockEnsureRunnerSession .mockResolvedValueOnce(makeRunnerSession()) diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 06cf3e66d1..757ff09cf1 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -625,12 +625,39 @@ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { */ export function isRunnerPreSendRefusal(error: unknown): boolean { return ( - shouldRestartRunnerBeforeCommandSend(error) || + (shouldRestartRunnerBeforeCommandSend(error) && isRunnerCommandProvablyUnwritten(error)) || shouldRestartRunnerAfterReadinessPreflight(error) || isRunnerBusyError(error) ); } +/** + * Marks a transport failure the transport raised before it wrote any request bytes: the + * connection never opened. A failure without the mark may have written the command. + */ +export function markRunnerCommandUnwritten(error: Failure): Failure { + if (error instanceof AppError) error.details = { ...error.details, runnerCommandUnwritten: true }; + return error; +} + +/** + * Whether a connect attempt provably wrote nothing: its transport marked it, or the connection + * was refused (`ECONNREFUSED` on every address), which happens before a request byte leaves. + */ +export function isRunnerCommandProvablyUnwritten(error: unknown): boolean { + if (error instanceof AppError && error.details?.runnerCommandUnwritten === true) return true; + return isConnectionRefused(error, 0); +} + +function isConnectionRefused(error: unknown, depth: number): boolean { + if (depth > 4 || typeof error !== 'object' || error === null) return false; + if ((error as { code?: unknown }).code === 'ECONNREFUSED') return true; + if (error instanceof AggregateError && error.errors.length > 0) { + return error.errors.every((inner) => isConnectionRefused(inner, depth + 1)); + } + return isConnectionRefused((error as { cause?: unknown }).cause, depth + 1); +} + /** * The one classifier for "the runner did not reach the point of serving a command" (#2680). Every * path that stops the runner before it answers a request routes its failure through here, so the diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index a0bc612a19..5c8a1d4857 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -360,6 +360,7 @@ async function executeRunnerCommandAttempt( options, signal, restartReason: 'runner_connect_failed_before_command_send', + firstAttemptUnwritten: isRunnerPreSendRefusal(appErr), }); } if (session && shouldRestartRunnerAfterReadinessPreflight(appErr)) { @@ -372,6 +373,7 @@ async function executeRunnerCommandAttempt( signal, restartReason: 'runner_readiness_preflight_failed_before_command_send', recoveredDiagnosticPhase: 'ios_runner_readiness_preflight_recovered', + firstAttemptUnwritten: true, }); } // Status recovery answers "did the command I lost the response to run?". A structured reply @@ -403,6 +405,11 @@ async function restartSessionAndRunCommand(params: { | 'runner_connect_failed_before_command_send' | 'runner_readiness_preflight_failed_before_command_send'; recoveredDiagnosticPhase?: string; + /** + * The failed first attempt provably never wrote the command. When it may have, the replay can + * double-send, and no failure of this restart may claim `no`. + */ + firstAttemptUnwritten: boolean; }): Promise> { const { device, command, options, signal, restartReason } = params; // At most one recycle per request: when the budget is spent, fail fast and KEEP the current @@ -410,15 +417,17 @@ async function restartSessionAndRunCommand(params: { // cheaply, and a dead process is detected and cleaned by the next ensureRunnerSession (#1105). const recycleKey = runnerRecycleLedgerKey(options, command); if (!tryBeginRunnerRecycle(recycleKey)) { - throw buildRunnerRecycleBudgetExhaustedError(command, options); + throw discloseDispatch( + buildRunnerRecycleBudgetExhaustedError(command, options), + params.firstAttemptUnwritten ? 'no' : 'unknown', + ); } await invalidateRunnerSession(params.session, restartReason); const restartedSession = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true, }).catch((error: unknown) => { - const restartError = markRunnerRestartError(error, params); - throw restartError instanceof AppError ? discloseDispatch(restartError, 'no') : restartError; + throw markRunnerRestartError(error, params); }); commitRunnerRecycle(recycleKey); try { @@ -469,12 +478,12 @@ function markRunnerRestartError( error: unknown, params: Pick< Parameters[0], - 'session' | 'command' | 'options' | 'restartReason' + 'session' | 'command' | 'options' | 'restartReason' | 'firstAttemptUnwritten' >, restartedSession?: RunnerSession, ): unknown { if (!(error instanceof AppError)) return error; - return new AppError( + const marked = new AppError( error.code, error.message, { @@ -491,6 +500,21 @@ function markRunnerRestartError( }, error.cause ?? error, ); + return discloseRestartDispatch(marked, params.firstAttemptUnwritten, restartedSession); +} + +/** + * A restart that never replayed says what the first attempt did; a replay after a first attempt + * that may have written the command cannot claim `no` for the two sends together. + */ +function discloseRestartDispatch( + error: AppError, + firstAttemptUnwritten: boolean, + restartedSession: RunnerSession | undefined, +): AppError { + if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); + if (firstAttemptUnwritten || error.details?.dispatched === 'yes') return error; + return discloseDispatch(error, 'unknown'); } async function runPrepareHealthCheck( diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 425846d9ab..50d17e52bb 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -22,7 +22,9 @@ import { import { classifyRunnerStartupFailure, enrichRunnerStartupFailureWithDeviceStates, + isRunnerCommandProvablyUnwritten, isUsbmuxDeviceUnattachedError, + markRunnerCommandUnwritten, RUNNER_CACHE_RECOVERY_HINT, runnerConnectFailureDetails, shouldRetryRunnerConnectError, @@ -72,6 +74,9 @@ export async function waitForRunner( const { resolveRoute, markUsbmuxUnattached } = createRunnerCommandRouteResolver(device, port); let route = await resolveRoute(deadline.remainingMs()); let lastError: unknown = null; + // Every attempt posts the command itself, so the loop gives up "before send" only when no + // attempt could have written it. + let commandMayHaveBeenWritten = false; const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS)); try { return await retryWithPolicy( @@ -93,6 +98,7 @@ export async function waitForRunner( }, setLastError: (err) => { lastError = err; + if (!isRunnerCommandProvablyUnwritten(err)) commandMayHaveBeenWritten = true; }, }); if (response) return response; @@ -134,22 +140,51 @@ export async function waitForRunner( if (device.kind === 'simulator') { const remainingMs = deadline.remainingMs(); if (remainingMs <= 0) { - throw buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }); + throw withRunnerWriteEvidence( + buildRunnerConnectError({ port, endpoints: route.endpoints, logPath, lastError }), + commandMayHaveBeenWritten, + ); } - const simResponse = await postCommandViaSimulator(device, port, command, remainingMs, signal); + const simResponse = await postCommandViaSimulator( + device, + port, + command, + remainingMs, + signal, + ).catch((error: unknown) => { + throw withRunnerWriteEvidence(error, commandMayHaveBeenWritten); + }); return new Response(simResponse.body, { status: simResponse.status }); } if (session?.child.exitCode !== null && session?.child.exitCode !== undefined) { throw await buildRunnerEarlyExitError({ session, port, logPath }); } - throw buildRunnerConnectError({ - port, - endpoints: route.endpoints, - logPath, - lastError, - deviceStates: session?.startupDeviceStates, - }); + throw withRunnerWriteEvidence( + buildRunnerConnectError({ + port, + endpoints: route.endpoints, + logPath, + lastError, + deviceStates: session?.startupDeviceStates, + }), + commandMayHaveBeenWritten, + ); +} + +/** + * A connect failure is unwritten only when no attempt of the loop could have written the command + * and the failure itself carries no write of its own. + */ +function withRunnerWriteEvidence(error: unknown, commandMayHaveBeenWritten: boolean): unknown { + if (!(error instanceof AppError)) return error; + if (commandMayHaveBeenWritten) { + error.details = { ...error.details, runnerCommandUnwritten: false }; + return error; + } + return error.details?.runnerCommandUnwritten === false + ? error + : markRunnerCommandUnwritten(error); } type RunnerRouteResolver = ReturnType['resolveRoute']; @@ -338,10 +373,12 @@ async function tryRunnerRoute( try { const remainingMs = params.attemptDeadline?.remainingMs() ?? params.timeoutMs; if (remainingMs <= 0) { - throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { - port: params.port, - timeoutMs: params.timeoutMs, - }); + throw markRunnerCommandUnwritten( + new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { + port: params.port, + timeoutMs: params.timeoutMs, + }), + ); } return await usbmuxRunnerTransport.postCommand( device.id, @@ -385,10 +422,12 @@ async function tryRunnerEndpoints( try { const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs; if (remainingMs <= 0) { - throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { - port, - timeoutMs, - }); + throw markRunnerCommandUnwritten( + new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { + port, + timeoutMs, + }), + ); } return await fetchWithTimeout( endpoint, @@ -435,6 +474,8 @@ async function tryRunnerSimulatorEndpoint( } } +const CURL_COULD_NOT_CONNECT_EXIT_CODE = 7; + async function postCommandViaSimulator( device: DeviceInfo, port: number, @@ -471,6 +512,8 @@ async function postCommandViaSimulator( reason, hint: bootFailureHint(reason), ...runnerConnectFailureDetails('runner_connect_refused'), + // curl exit 7: it could not connect, so it sent nothing. Any other exit may follow the POST. + runnerCommandUnwritten: result.exitCode === CURL_COULD_NOT_CONNECT_EXIT_CODE, }; }, ); diff --git a/packages/platform-apple/src/runner/runner-usbmux.ts b/packages/platform-apple/src/runner/runner-usbmux.ts index 8eb18ca228..d646c28d6a 100644 --- a/packages/platform-apple/src/runner/runner-usbmux.ts +++ b/packages/platform-apple/src/runner/runner-usbmux.ts @@ -4,6 +4,7 @@ import { type Socket } from 'node:net'; import { Deadline } from './host.ts'; import type { RunnerCommand } from './runner-contract.ts'; import { openUsbmuxRunnerSocket } from './runner-usbmux-protocol.ts'; +import { markRunnerCommandUnwritten } from './runner-error-classification.ts'; const USBMUXD_SOCKET_PATH = '/var/run/usbmuxd'; const RUNNER_HTTP_MAX_BODY_BYTES = 64 * 1024 * 1024; @@ -28,7 +29,9 @@ export function createUsbmuxRunnerTransport(socketPath: string): UsbmuxRunnerTra port, deadline.remainingMs(), signal, - ); + ).catch((error: unknown) => { + throw markRunnerCommandUnwritten(error); + }); try { return await postRunnerHttpCommand(socket, command, deadline.remainingMs(), signal); } catch (error) { diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 893772d010..2b0c6dfc3a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -33,12 +33,17 @@ beforeEach(() => { }); type PressScenario = { + command?: 'press' | 'get'; positionals: string[]; /** Runs inside the device touch, before it fails. */ duringTouch?: (store: ReturnType, sessionName: string) => void; }; -async function press({ positionals, duringTouch }: PressScenario): Promise { +async function press({ + command = 'press', + positionals, + duringTouch, +}: PressScenario): Promise { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure'); session.snapshot = { @@ -63,13 +68,13 @@ async function press({ positionals, duringTouch }: PressScenario): Promise Promise> = { store.set(name, { ...current }); }, }), + 'daemon.read-only-command': () => + press({ command: 'get', positionals: ['text', 'label="Missing"'] }), 'post-action-guard.android-press-left-app': pressThatLeftTheApp, }; diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts index e7c07bc463..015afa0c5a 100644 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -1,15 +1,24 @@ -import { AppError, discloseUnclassifiedDispatch } from '@agent-device/kernel/errors'; -import type { DaemonResponse } from '../../daemon-request.ts'; +import { + AppError, + type DispatchDisclosure, + discloseUnclassifiedDispatch, +} from '@agent-device/kernel/errors'; +import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; +import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; /** - * The verdict for an interaction failure no producer classified: `unknown`. Only a producer that - * refuses before dispatch may say `no`, and only one that proved execution may say `yes`; this - * layer cannot tell either from where the failure surfaced, so it keeps any producer's verdict and - * otherwise claims nothing. + * The verdict for an interaction failure no producer classified. A request the registry declares + * read-only (`recordingEffect: 'observes-app'`) never dispatches a mutation, so it is `no`. + * Otherwise `unknown`: only a producer that refuses before dispatch may say `no`, and only one that + * proved execution may say `yes`, and this layer cannot tell either from where the failure surfaced. + * A producer's own verdict is always kept. */ export async function discloseUnclassifiedInteractionDispatch( + req: DaemonRequest, dispatch: () => Promise, ): Promise { + const verdict: DispatchDisclosure = + resolveCommandRecordingEffect(req) === 'observes-app' ? 'no' : 'unknown'; try { const response = await dispatch(); if (!response || response.ok || response.error.details?.dispatched !== undefined) { @@ -19,11 +28,11 @@ export async function discloseUnclassifiedInteractionDispatch( ok: false, error: { ...response.error, - details: { ...response.error.details, dispatched: 'unknown' }, + details: { ...response.error.details, dispatched: verdict }, }, }; } catch (error) { - if (error instanceof AppError) throw discloseUnclassifiedDispatch(error, 'unknown'); + if (error instanceof AppError) throw discloseUnclassifiedDispatch(error, verdict); throw error; } } diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index ef51e374c3..be17df6bd3 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -28,6 +28,7 @@ export async function handleInteractionCommands( const captureProof: RequestCaptureProof = {}; const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; const response = await discloseUnclassifiedInteractionDispatch( + params.req, async () => await dispatchInteractionCommand(routed), ); return response From 6907cabfed6e7d865f22631ab18af461494009b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 14:01:26 +0200 Subject: [PATCH 07/42] docs(interaction): explain dispatched and the runner refusal reasons --- website/docs/docs/client-api.md | 2 ++ website/docs/docs/commands.md | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index f5a707514d..e73b23b98e 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -234,6 +234,8 @@ Use `client.command.()` for command-level device actions. It uses the sa Results are daemon-shaped objects with typed known fields, so command semantics stay aligned with the CLI. +A failed interaction rejects with the same error the CLI prints. Read `error.details.dispatched` before you retry; [Commands](./commands.md) explains the three values. + ```ts await client.command.wait({ text: 'Continue', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 4c9090135d..2ab74ccab7 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -454,6 +454,7 @@ agent-device alert dismiss - Use `alert get` for an immediate cheap check. Use `alert wait ` only when a prompt may appear after async work. - Within an iOS XCTest execution, `accept` and `dismiss` activate the selected button once, then only observe until the alert disappears, its presentation changes, or the deadline expires. A shared button label never triggers a second coordinate tap. A changed presentation can be an updated original alert or a replacement; it does not prove a permission was granted. Verify the application outcome separately. - An unreadable or ambiguous post-action capture fails with `error.details.runnerErrorCode: ALERT_CONFIRMATION_UNAVAILABLE`; an expired runner deadline uses `ALERT_DEADLINE_EXCEEDED` (the outer command watchdog can also report a timeout). Neither proves absence or that no action occurred. Identical-looking alerts remain unconfirmed. Inspect the current alert before deciding whether to act again. +- iOS runner refusals carry `error.details.reason`: `runner_busy` means the runner was still finishing an earlier command and ran nothing (`dispatched: no`); `runner_main_thread_timeout` means the runner gave up waiting on the app, and the action may still land (`dispatched: unknown`). - Android support is snapshot-derived. If `alert` reports no alert but a sheet is visible, treat it as app-owned UI and use `snapshot -i` plus `press` by visible label/ref. - If an iOS permission sheet is visible in `snapshot` or `screenshot` but `alert accept` reports no alert, fall back to a scoped `snapshot -i -s ""` plus `press @ref`; not every simulator permission surface is exposed as a native XCTest alert. @@ -488,7 +489,13 @@ agent-device gesture transform 200 420 80 -40 2 35 700 # combined pan, zoom, and ``` `fill` clears then types. `type` does not clear. -A failed interaction carries `error.details.dispatched` when its producer could classify it: `no` means the action provably never reached the device, `yes` means it executed and failed, and `unknown` means it may have landed, so observe the screen before retrying; a failure without the field was not classified. +When an interaction fails, read `error.details.dispatched` before you retry: + +- `no`: the action never reached the device. Retry it as it is. +- `yes`: the action ran on the device and failed after that. Take a snapshot and decide from what you see. +- `unknown`: the action may have landed. Take a snapshot before you retry; a blind retry can tap, type, or navigate twice. + +A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. On iOS, if `type "\n"` reports `TEXT_INPUT_SYNTHESIS_UNAVAILABLE` after tapping a field while the software keyboard is hidden, show the software keyboard, then retry. The runner reports this error instead of risking input through an unreliable text-entry path. From 9598b707cc45575b772793be67017388a2cd59fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 14:49:31 +0200 Subject: [PATCH 08/42] refactor(apple-runner): the transport discloses dispatch itself instead of a write-evidence flag The runner transport is the only code that knows whether request bytes left, so it now stamps the public `details.dispatched` where it throws, replacing the private `runnerCommandUnwritten` details flag: - usbmux socket refusal, per-attempt connection deadline, simctl curl exit 7: `dispatched: no`; simctl curl with any other exit: `unknown`. - the connect loop's aggregate: `unknown` (overwriting) when any attempt may have written the command, else fill-if-absent `no`. - isRunnerCommandProvablyUnwritten reads `dispatched === 'no'` or a refused connection; markRunnerCommandUnwritten is deleted. Mutation checks: removing both `no` stamps (aggregate fill and curl exit 7) fails the golden row ios-runner.pre-send.connect-refused-before-write; stamping curl exit 7 as `unknown` fails it too; dropping the aggregate `unknown` overwrite fails the new waitForRunner test for a written attempt followed by a refused curl fallback. --- .../runner-startup-transport.test.ts | 17 +++++++++++ .../src/runner/runner-error-classification.ts | 16 +++-------- .../src/runner/runner-startup-transport.ts | 28 ++++++++++--------- .../src/runner/runner-usbmux.ts | 9 ++++-- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts index 319981ba76..7bc6ccdeae 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts @@ -158,6 +158,23 @@ test('waitForRunner types a failed simulator fallback as a refused connection', assert.equal(mockRunCmd.mock.calls.length, 1); }); +test('waitForRunner discloses unknown when an attempt may have written before a refused fallback', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new AppError('COMMAND_FAILED', 'Runner command deadline exceeded')), + ); + mockRunCmd.mockResolvedValue({ exitCode: 7, stdout: '', stderr: 'curl: (7) Failed to connect' }); + + await assert.rejects( + () => waitForRunner(iosSimulator, 8100, { command: 'tap', x: 1, y: 1 }, undefined, 100), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }, + ); +}); + test('waitForRunner wakes a simulator startup retry when the listener reports ready', async () => { vi.useFakeTimers(); const readiness = new AbortController(); diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 757ff09cf1..772932e398 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -632,20 +632,12 @@ export function isRunnerPreSendRefusal(error: unknown): boolean { } /** - * Marks a transport failure the transport raised before it wrote any request bytes: the - * connection never opened. A failure without the mark may have written the command. - */ -export function markRunnerCommandUnwritten(error: Failure): Failure { - if (error instanceof AppError) error.details = { ...error.details, runnerCommandUnwritten: true }; - return error; -} - -/** - * Whether a connect attempt provably wrote nothing: its transport marked it, or the connection - * was refused (`ECONNREFUSED` on every address), which happens before a request byte leaves. + * Whether a connect attempt provably wrote nothing: its transport disclosed `dispatched: no`, or + * the connection was refused (`ECONNREFUSED` on every address), which happens before a request + * byte leaves. */ export function isRunnerCommandProvablyUnwritten(error: unknown): boolean { - if (error instanceof AppError && error.details?.runnerCommandUnwritten === true) return true; + if (error instanceof AppError && error.details?.dispatched === 'no') return true; return isConnectionRefused(error, 0); } diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 50d17e52bb..62be0d2b7c 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -1,5 +1,8 @@ import { createRequestCanceledError, + discloseDispatch, + discloseUnclassifiedDispatch, + type DispatchDisclosure, isRequestCanceledError, AppError, } from '@agent-device/kernel/errors'; @@ -24,7 +27,6 @@ import { enrichRunnerStartupFailureWithDeviceStates, isRunnerCommandProvablyUnwritten, isUsbmuxDeviceUnattachedError, - markRunnerCommandUnwritten, RUNNER_CACHE_RECOVERY_HINT, runnerConnectFailureDetails, shouldRetryRunnerConnectError, @@ -173,18 +175,14 @@ export async function waitForRunner( } /** - * A connect failure is unwritten only when no attempt of the loop could have written the command - * and the failure itself carries no write of its own. + * An earlier attempt that may have written the command may also have executed it, so the loop's + * failure is `unknown` whatever a later refused attempt stamped. */ function withRunnerWriteEvidence(error: unknown, commandMayHaveBeenWritten: boolean): unknown { if (!(error instanceof AppError)) return error; - if (commandMayHaveBeenWritten) { - error.details = { ...error.details, runnerCommandUnwritten: false }; - return error; - } - return error.details?.runnerCommandUnwritten === false - ? error - : markRunnerCommandUnwritten(error); + return commandMayHaveBeenWritten + ? discloseDispatch(error, 'unknown') + : discloseUnclassifiedDispatch(error, 'no'); } type RunnerRouteResolver = ReturnType['resolveRoute']; @@ -373,11 +371,12 @@ async function tryRunnerRoute( try { const remainingMs = params.attemptDeadline?.remainingMs() ?? params.timeoutMs; if (remainingMs <= 0) { - throw markRunnerCommandUnwritten( + throw discloseDispatch( new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { port: params.port, timeoutMs: params.timeoutMs, }), + 'no', ); } return await usbmuxRunnerTransport.postCommand( @@ -422,11 +421,12 @@ async function tryRunnerEndpoints( try { const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs; if (remainingMs <= 0) { - throw markRunnerCommandUnwritten( + throw discloseDispatch( new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', { port, timeoutMs, }), + 'no', ); } return await fetchWithTimeout( @@ -513,7 +513,9 @@ async function postCommandViaSimulator( hint: bootFailureHint(reason), ...runnerConnectFailureDetails('runner_connect_refused'), // curl exit 7: it could not connect, so it sent nothing. Any other exit may follow the POST. - runnerCommandUnwritten: result.exitCode === CURL_COULD_NOT_CONNECT_EXIT_CODE, + dispatched: (result.exitCode === CURL_COULD_NOT_CONNECT_EXIT_CODE + ? 'no' + : 'unknown') satisfies DispatchDisclosure, }; }, ); diff --git a/packages/platform-apple/src/runner/runner-usbmux.ts b/packages/platform-apple/src/runner/runner-usbmux.ts index d646c28d6a..eb1fb65427 100644 --- a/packages/platform-apple/src/runner/runner-usbmux.ts +++ b/packages/platform-apple/src/runner/runner-usbmux.ts @@ -1,10 +1,13 @@ -import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { + AppError, + createRequestCanceledError, + discloseDispatch, +} from '@agent-device/kernel/errors'; import http, { type IncomingMessage } from 'node:http'; import { type Socket } from 'node:net'; import { Deadline } from './host.ts'; import type { RunnerCommand } from './runner-contract.ts'; import { openUsbmuxRunnerSocket } from './runner-usbmux-protocol.ts'; -import { markRunnerCommandUnwritten } from './runner-error-classification.ts'; const USBMUXD_SOCKET_PATH = '/var/run/usbmuxd'; const RUNNER_HTTP_MAX_BODY_BYTES = 64 * 1024 * 1024; @@ -30,7 +33,7 @@ export function createUsbmuxRunnerTransport(socketPath: string): UsbmuxRunnerTra deadline.remainingMs(), signal, ).catch((error: unknown) => { - throw markRunnerCommandUnwritten(error); + throw error instanceof AppError ? discloseDispatch(error, 'no') : error; }); try { return await postRunnerHttpCommand(socket, command, deadline.remainingMs(), signal); From 6836aaec1f2aa69ea2c7dbe190454c7b8dfb08b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 14:52:35 +0200 Subject: [PATCH 09/42] refactor(contracts): drop the unread phase column from the dispatch-disclosure table No consumer read the before-seam/after-seam phase beyond a vocabulary membership check; the daemon seam does not infer a verdict from it. Removes the column from all 46 rows, the row type, DISPATCH_DISCLOSURE_PHASES, and the check. --- contracts/fixtures/dispatch-disclosure.json | 46 ------------------- .../src/dispatch-disclosure.fixtures.ts | 3 -- .../contracts/src/dispatch-disclosure.test.ts | 2 - 3 files changed, 51 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index d546bba690..42d612a842 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -1,49 +1,42 @@ [ { "id": "daemon.refusal.ref-not-found", - "phase": "before-seam", "producer": "daemon", "trigger": "target resolution refused the @ref (refMissRefusal) before any backend call", "dispatched": "no" }, { "id": "daemon.refusal.admission", - "phase": "before-seam", "producer": "daemon", "trigger": "targeted-touch admission refused the request (admitTargetedTouch) before any backend call", "dispatched": "no" }, { "id": "daemon.unclassified", - "phase": "after-seam", "producer": "daemon", "trigger": "an interaction failure no producer classified; the fallback is unknown whatever the session runtime revision did", "dispatched": "unknown" }, { "id": "daemon.unclassified.session-replaced", - "phase": "after-seam", "producer": "daemon", "trigger": "an unclassified failure after the session object was replaced mid-request", "dispatched": "unknown" }, { "id": "daemon.read-only-command", - "phase": "after-seam", "producer": "daemon", "trigger": "an unclassified failure of a command the registry declares read-only (recordingEffect observes-app), e.g. get", "dispatched": "no" }, { "id": "daemon.scroll-no-progress", - "phase": "after-seam", "producer": "daemon", "trigger": "scroll_no_progress: the scroll gesture ran and the container, still hiding content, never shifted", "dispatched": "yes" }, { "id": "maestro-direct.fallback.pre-send-refusal", - "phase": "after-seam", "producer": "daemon", "trigger": "the direct iOS selector tap failed with a runner refusal disclosed no (connect refused before send); the tree path taps instead", "dispatched": "no", @@ -51,7 +44,6 @@ }, { "id": "maestro-direct.fallback.lost-reply", - "phase": "after-seam", "producer": "daemon", "trigger": "the direct iOS selector tap lost its reply and the status probe failed (unknown); the tap may have landed, so the tree path must not tap again", "dispatched": "unknown", @@ -59,252 +51,216 @@ }, { "id": "post-action-guard.android-press-left-app", - "phase": "after-seam", "producer": "post-action-guard", "trigger": "assertAndroidPressStayedInApp observed an escape surface in the foreground", "dispatched": "yes" }, { "id": "ios-runner.reply.RUNNER_BUSY", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code RUNNER_BUSY: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.RUNNER_WEDGED", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code RUNNER_WEDGED: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.APP_NOT_RUNNING", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code APP_NOT_RUNNING: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.SCROLL_KEYBOARD_OCCLUDES_SURFACE", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code SCROLL_KEYBOARD_OCCLUDES_SURFACE: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.ALERT_NOT_FOUND", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code ALERT_NOT_FOUND: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.APP_SCREEN_WINDOW_UNRESOLVED", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code APP_SCREEN_WINDOW_UNRESOLVED: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.APP_SCREEN_UNRESOLVED", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code APP_SCREEN_UNRESOLVED: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.APP_SCREEN_CAPTURE_UNRENDERABLE", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code APP_SCREEN_CAPTURE_UNRENDERABLE: a refusal that ran nothing", "dispatched": "no" }, { "id": "ios-runner.reply.ELEMENT_NOT_FOUND", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code ELEMENT_NOT_FOUND: a selector refusal before any gesture", "dispatched": "no" }, { "id": "ios-runner.reply.ELEMENT_OFFSCREEN", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code ELEMENT_OFFSCREEN: a selector refusal before any gesture", "dispatched": "no" }, { "id": "ios-runner.reply.AMBIGUOUS_MATCH", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code AMBIGUOUS_MATCH: a selector refusal before any gesture", "dispatched": "no" }, { "id": "ios-runner.reply.MAIN_THREAD_TIMEOUT", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with code MAIN_THREAD_TIMEOUT: abandoned work may still land", "dispatched": "unknown" }, { "id": "ios-runner.reply.executed-failure", - "phase": "after-seam", "producer": "ios-runner", "trigger": "structured runner reply with any other code, e.g. XCTEST_RECORDED_FAILURE", "dispatched": "yes" }, { "id": "ios-runner.pre-send.session-start-failed", - "phase": "before-seam", "producer": "ios-runner", "trigger": "the runner session could not start, so the command never reached the exchange", "dispatched": "no" }, { "id": "ios-runner.pre-send.connect-refused-before-write", - "phase": "before-seam", "producer": "ios-runner", "trigger": "every connect attempt was refused before a byte was written (ECONNREFUSED, simctl curl exit 7), and the restart that would replay the command failed", "dispatched": "no" }, { "id": "ios-runner.transport.written-then-lost", - "phase": "after-seam", "producer": "ios-runner", "trigger": "a connect attempt posted the command and then timed out (fetch deadline, simctl curl exit 28); the failure keeps the runner_connect_refused restart verdict, and the restart that replays it failed", "dispatched": "unknown" }, { "id": "ios-runner.pre-send.readiness-preflight-after-restart", - "phase": "before-seam", "producer": "ios-runner", "trigger": "the readiness preflight gave up before the command was written, again after the restart", "dispatched": "no" }, { "id": "ios-runner.status.failed", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState failed", "dispatched": "yes" }, { "id": "ios-runner.status.failed-RUNNER_BUSY", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState failed with journal code RUNNER_BUSY", "dispatched": "no" }, { "id": "ios-runner.status.completed-without-retained-reply", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState completed without a readable retained reply", "dispatched": "yes" }, { "id": "ios-runner.status.accepted", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState accepted", "dispatched": "unknown" }, { "id": "ios-runner.status.started", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState started", "dispatched": "unknown" }, { "id": "ios-runner.status.notAccepted", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState notAccepted (the journal may not have survived a restart)", "dispatched": "unknown" }, { "id": "ios-runner.status.probe-failed", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; the status probe itself failed", "dispatched": "unknown" }, { "id": "ios-runner.status.unavailable", - "phase": "after-seam", "producer": "ios-runner", "trigger": "transport lost; the command carries no commandId to probe", "dispatched": "unknown" }, { "id": "android-adb.input-tap.tool-missing", - "phase": "after-seam", "producer": "android-adb", "trigger": "adb input tap could not start: TOOL_MISSING", "dispatched": "no" }, { "id": "android-adb.input-tap.failed", - "phase": "after-seam", "producer": "android-adb", "trigger": "adb input tap started and failed", "dispatched": "unknown" }, { "id": "android-adb.input-text.failed-after-chunk", - "phase": "after-seam", "producer": "android-adb", "trigger": "adb input text failed after at least one chunk was typed (details.dispatchedSteps)", "dispatched": "unknown" }, { "id": "android-adb.fill.unverified", - "phase": "after-seam", "producer": "android-adb", "trigger": "fill typed the text and its verification still failed after the last clear-and-retype pass. A throw from inside the second pass keeps that pass's adb input verdict (unknown with dispatchedSteps), though the first pass already typed", "dispatched": "yes" }, { "id": "android-helper.gesture.reported-failure", - "phase": "after-seam", "producer": "android-helper", "trigger": "one-shot helper gesture returned a parsed final result with ok=false", "dispatched": "yes" }, { "id": "android-helper.gesture.failed-after-result", - "phase": "after-seam", "producer": "android-helper", "trigger": "one-shot helper gesture returned a parsed final result and exited non-zero", "dispatched": "yes" }, { "id": "android-helper.gesture.no-parseable-output", - "phase": "after-seam", "producer": "android-helper", "trigger": "one-shot helper gesture failed before returning parseable output", "dispatched": "unknown" }, { "id": "android-helper.gesture-session.reported-failure", - "phase": "after-seam", "producer": "android-helper", "trigger": "persistent helper session answered the gesture with ok=false", "dispatched": "yes" }, { "id": "android-helper.gesture-session.transport-failure", - "phase": "after-seam", "producer": "android-helper", "trigger": "persistent helper session transport failed during the gesture", "dispatched": "unknown" }, { "id": "webdriver.connect-refused", - "phase": "after-seam", "producer": "webdriver", "trigger": "connect-refused", "dispatched": "no", @@ -312,7 +268,6 @@ }, { "id": "webdriver.timeout-after-send", - "phase": "after-seam", "producer": "webdriver", "trigger": "timeout-after-send", "dispatched": "unknown", @@ -320,7 +275,6 @@ }, { "id": "webdriver.http-5xx-after-send", - "phase": "after-seam", "producer": "webdriver", "trigger": "http-5xx-after-send", "dispatched": "unknown", diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index d181213eba..6e4b1620df 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -3,8 +3,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { DispatchDisclosure } from '@agent-device/kernel/errors'; -export const DISPATCH_DISCLOSURE_PHASES = ['before-seam', 'after-seam'] as const; - export const DISPATCH_DISCLOSURE_PRODUCERS = [ 'daemon', 'ios-runner', @@ -18,7 +16,6 @@ export type DispatchDisclosureProducer = (typeof DISPATCH_DISCLOSURE_PRODUCERS)[ export type DispatchDisclosureRow = { id: string; - phase: (typeof DISPATCH_DISCLOSURE_PHASES)[number]; producer: DispatchDisclosureProducer; trigger: string; dispatched: DispatchDisclosure; diff --git a/packages/contracts/src/dispatch-disclosure.test.ts b/packages/contracts/src/dispatch-disclosure.test.ts index ab6f8de956..683ebdf927 100644 --- a/packages/contracts/src/dispatch-disclosure.test.ts +++ b/packages/contracts/src/dispatch-disclosure.test.ts @@ -10,7 +10,6 @@ import { } from '@agent-device/kernel/errors'; import { DISPATCH_DISCLOSURE_DRIVER_OWNERS, - DISPATCH_DISCLOSURE_PHASES, DISPATCH_DISCLOSURE_PRODUCERS, dispatchDisclosureDriverOwner, DISPATCH_DISCLOSURE_TABLE_PATH, @@ -37,7 +36,6 @@ test('dispatch-disclosure rows are unique and use the declared vocabulary', () = assert.ok(rows.length > 0); assert.equal(new Set(rows.map((row) => row.id)).size, rows.length, 'row ids must be unique'); for (const row of rows) { - assert.ok((DISPATCH_DISCLOSURE_PHASES as readonly string[]).includes(row.phase), row.id); assert.ok((DISPATCH_DISCLOSURE_PRODUCERS as readonly string[]).includes(row.producer), row.id); assert.ok(DISPATCH_VALUES.includes(row.dispatched), row.id); assert.ok(row.trigger.trim().length > 0, row.id); From 0f2c10722c9b2326ff01d3b7e72710a4d58101b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 15:00:49 +0200 Subject: [PATCH 10/42] fix(daemon): a read-only command discloses dispatched no over any producer verdict `dispatched` answers whether a resend is safe. A command the registry declares read-only (`recordingEffect: 'observes-app'`) has no side effect, so the interaction seam now sets `no` for it over a producer's `unknown` or `yes`, on both the thrown and the response path. Mutations keep the fill-if-absent `unknown`. The seam is renamed discloseInteractionDispatch since it no longer only fills. The golden row daemon.read-only-command now states it applies even when a producer classified the failure. ADR 0011 and the commands docs name the rule. Mutation checks: reverting the response path to fill-if-absent fails "a read-only command discloses no over a producer verdict" (get text whose capture throws dispatched unknown/yes); reverting the thrown path fails "a read-only command discloses no over a producer verdict it throws". Consumers unaffected: direct-ios-selector's fallback check and the runner lifecycle read `dispatched` inside the producer, before the seam, and only for tap/interaction commands. --- contracts/fixtures/dispatch-disclosure.json | 2 +- .../0011-interaction-guarantee-contract.md | 4 +- .../interaction-dispatch-disclosure.test.ts | 41 +++++++++++++++++++ .../interaction-dispatch-disclosure.ts | 31 ++++++-------- .../interaction/internal/interaction.ts | 4 +- website/docs/docs/commands.md | 1 + 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 42d612a842..f69e137f50 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -26,7 +26,7 @@ { "id": "daemon.read-only-command", "producer": "daemon", - "trigger": "an unclassified failure of a command the registry declares read-only (recordingEffect observes-app), e.g. get", + "trigger": "any failure of a command the registry declares read-only (recordingEffect observes-app), e.g. get, even one a producer classified unknown or yes: a read has no side effect", "dispatched": "no" }, { diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index ad2848bd27..a06c596a7a 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -158,7 +158,9 @@ dispatch (target resolution, admission, a runner pre-send refusal) may say `unknown` once, around interaction dispatch, for a failure no producer classified, and never overwrites a producer's value: a wrong `no` makes a consumer resend an action that already ran, while a wrong `unknown` only costs -an observation. Each row names its driver +an observation. The one exception is a read-only command (registry +`recordingEffect: 'observes-app'`): the daemon sets `no` over any producer +value, because a read has no side effect and is always safe to resend. Each row names its driver file by id prefix, that file drives the real producer, and a row marked `implementedBy` waits for the branch that ships it. diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 2b0c6dfc3a..02e5472ef7 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -16,6 +16,8 @@ import { mockTapPoint, resetGetRuntimeFixture, } from '../../../__tests__/interaction-get-runtime-fixture.ts'; +import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; +import { discloseInteractionDispatch } from '../interaction-dispatch-disclosure.ts'; import { handleInteractionCommands } from '../../index.ts'; import { assertAndroidPressStayedInApp } from '../interaction-android-escape.ts'; import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; @@ -152,3 +154,42 @@ test('the daemon keeps a producer verdict instead of inferring its own', async ( }); } }); + +test('a read-only command discloses no over a producer verdict', async () => { + const capture = vi.mocked(captureSnapshotWithInteractor); + capture.mockClear(); + for (const dispatched of ['unknown', 'yes'] satisfies DispatchDisclosure[]) { + capture.mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'runner capture lost', { dispatched }), + ); + await assert.rejects( + press({ command: 'get', positionals: ['text', 'label="Missing"'] }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'runner capture lost'); + assert.equal(error.details?.dispatched, 'no'); + return true; + }, + ); + } + assert.equal(capture.mock.calls.length, 2); +}); + +test('a read-only command discloses no over a producer verdict it throws', async () => { + const producerFailure = new AppError('COMMAND_FAILED', 'runner capture lost', { + dispatched: 'unknown', + }); + await assert.rejects( + discloseInteractionDispatch( + { token: 't', session: 's', command: 'get', positionals: ['text', 'label="Missing"'] }, + async () => { + throw producerFailure; + }, + ), + (error: unknown) => { + assert.equal(error, producerFailure); + assert.equal(producerFailure.details?.dispatched, 'no'); + return true; + }, + ); +}); diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts index 015afa0c5a..002c13552d 100644 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -1,39 +1,34 @@ import { AppError, - type DispatchDisclosure, + discloseDispatch, discloseUnclassifiedDispatch, } from '@agent-device/kernel/errors'; import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; /** - * The verdict for an interaction failure no producer classified. A request the registry declares - * read-only (`recordingEffect: 'observes-app'`) never dispatches a mutation, so it is `no`. - * Otherwise `unknown`: only a producer that refuses before dispatch may say `no`, and only one that - * proved execution may say `yes`, and this layer cannot tell either from where the failure surfaced. - * A producer's own verdict is always kept. + * The daemon's verdict around interaction dispatch. A request the registry declares read-only + * (`recordingEffect: 'observes-app'`) is `no` over any producer verdict: a read has no side effect, + * so it is always safe to resend. Otherwise `unknown` fills only a failure no producer classified. */ -export async function discloseUnclassifiedInteractionDispatch( +export async function discloseInteractionDispatch( req: DaemonRequest, dispatch: () => Promise, ): Promise { - const verdict: DispatchDisclosure = - resolveCommandRecordingEffect(req) === 'observes-app' ? 'no' : 'unknown'; + const readOnly = resolveCommandRecordingEffect(req) === 'observes-app'; try { const response = await dispatch(); - if (!response || response.ok || response.error.details?.dispatched !== undefined) { - return response; - } + if (!response || response.ok) return response; + const producerVerdict = response.error.details?.dispatched; + const dispatched = readOnly ? 'no' : (producerVerdict ?? 'unknown'); + if (dispatched === producerVerdict) return response; return { ok: false, - error: { - ...response.error, - details: { ...response.error.details, dispatched: verdict }, - }, + error: { ...response.error, details: { ...response.error.details, dispatched } }, }; } catch (error) { - if (error instanceof AppError) throw discloseUnclassifiedDispatch(error, verdict); - throw error; + if (!(error instanceof AppError)) throw error; + throw readOnly ? discloseDispatch(error, 'no') : discloseUnclassifiedDispatch(error, 'unknown'); } } diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index be17df6bd3..080a746dec 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -4,7 +4,7 @@ import { type RequestCaptureProof, withCaptureDisclosures } from '../../capture- import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { dispatchFillViaRuntime } from './interaction-touch-fill.ts'; import { dispatchTargetedTouchViaRuntime } from './interaction-touch-press.ts'; -import { discloseUnclassifiedInteractionDispatch } from './interaction-dispatch-disclosure.ts'; +import { discloseInteractionDispatch } from './interaction-dispatch-disclosure.ts'; import { finalizeTouchInteraction } from './interaction-runtime.ts'; import { refSnapshotFlagGuardResponse } from '../../ref-snapshot-flag-policy.ts'; import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../../selector-runtime.ts'; @@ -27,7 +27,7 @@ export async function handleInteractionCommands( ): Promise { const captureProof: RequestCaptureProof = {}; const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; - const response = await discloseUnclassifiedInteractionDispatch( + const response = await discloseInteractionDispatch( params.req, async () => await dispatchInteractionCommand(routed), ); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2ab74ccab7..b3d2674054 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -495,6 +495,7 @@ When an interaction fails, read `error.details.dispatched` before you retry: - `yes`: the action ran on the device and failed after that. Take a snapshot and decide from what you see. - `unknown`: the action may have landed. Take a snapshot before you retry; a blind retry can tap, type, or navigate twice. +A read-only command such as `get` always reports `no`: it changes nothing, so a retry is safe. A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. From bd733bd76bc8ada365d22024c31ea2f41011ca61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 15:46:10 +0200 Subject: [PATCH 11/42] feat(errors)!: dispatched is two-valued, no or unknown No consumer acts differently on `yes` and `unknown`: the docs said snapshot on both, and the only branching consumer (the direct iOS selector fallback) keys on `no`. No producer can prove execution on its failure path either: the runner documents XCTEST_RECORDED_FAILURE as "the action may not have been performed", the Android helper's ok=false comes from an outer catch that also covers pre-injection failures, and WebDriver has no execution receipt. `DispatchDisclosure` is now `'no' | 'unknown'`. Every former `yes` producer: - runner reply table and status recovery: an unlisted or missing code, `completed` without a retained reply, and a journal `failed` without a refusal code are `unknown`. - post-action guard: stamps nothing; the daemon seam fills `unknown`. - Android helper ok=false (one-shot and session) and fill verification: stamp `unknown` themselves, so their platform-level drivers pin the verdict without the daemon seam. The helper result carries no count of events injected before the failure, so it cannot say `no`. - scroll_no_progress stamps `unknown` itself: `scroll` routes through the generic runtime, not the interaction seam, so nothing would fill it. Golden table: every row keeps its scenario; each `yes` verdict is now `unknown`, and triggers that claimed execution say what is proven. `ios-runner.reply.executed-failure` is renamed `unlisted-code`. Mutations: runner default `?? 'unknown'` -> `?? 'no'` fails ios-runner.reply.{MAIN_THREAD_TIMEOUT,unlisted-code} and status.failed; dropping the seam's `discloseUnclassifiedDispatch(error, 'unknown')` fails post-action-guard.android-press-left-app; dropping the scroll_no_progress stamp fails daemon.scroll-no-progress. --- contracts/fixtures/dispatch-disclosure.json | 36 +++++++++--------- .../0011-interaction-guarantee-contract.md | 16 +++++--- .../contracts/src/dispatch-disclosure.test.ts | 2 +- packages/kernel/src/errors.test.ts | 2 +- packages/kernel/src/errors.ts | 8 ++-- .../platform-android/src/fill-verification.ts | 2 +- .../src/snapshot-helper-session.ts | 15 +++++--- packages/platform-android/src/touch-helper.ts | 3 -- .../runner-dispatch-disclosure.test.ts | 2 +- .../src/runner/runner-command-recovery.ts | 4 +- .../src/runner/runner-contract.ts | 11 +++--- .../src/runner/runner-lifecycle.ts | 3 +- .../__tests__/direct-ios-selector.test.ts | 18 ++++----- src/daemon/direct-ios-selector.ts | 4 +- .../interaction-dispatch-disclosure.test.ts | 38 ++++++++++--------- .../internal/interaction-android-escape.ts | 2 +- src/daemon/scroll-movement.ts | 2 +- website/docs/docs/client-api.md | 2 +- website/docs/docs/commands.md | 3 +- 19 files changed, 88 insertions(+), 85 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index f69e137f50..c930c7c175 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -26,14 +26,14 @@ { "id": "daemon.read-only-command", "producer": "daemon", - "trigger": "any failure of a command the registry declares read-only (recordingEffect observes-app), e.g. get, even one a producer classified unknown or yes: a read has no side effect", + "trigger": "any failure of a command the registry declares read-only (recordingEffect observes-app), e.g. get, even one a producer classified unknown: a read has no side effect", "dispatched": "no" }, { "id": "daemon.scroll-no-progress", "producer": "daemon", - "trigger": "scroll_no_progress: the scroll gesture ran and the container, still hiding content, never shifted", - "dispatched": "yes" + "trigger": "scroll_no_progress: the scroll gesture was sent and the container, still hiding content, never shifted; nothing proves whether the gesture landed", + "dispatched": "unknown" }, { "id": "maestro-direct.fallback.pre-send-refusal", @@ -52,8 +52,8 @@ { "id": "post-action-guard.android-press-left-app", "producer": "post-action-guard", - "trigger": "assertAndroidPressStayedInApp observed an escape surface in the foreground", - "dispatched": "yes" + "trigger": "assertAndroidPressStayedInApp observed an escape surface in the foreground after the press; the guard stamps nothing and the daemon seam fills unknown", + "dispatched": "unknown" }, { "id": "ios-runner.reply.RUNNER_BUSY", @@ -128,10 +128,10 @@ "dispatched": "unknown" }, { - "id": "ios-runner.reply.executed-failure", + "id": "ios-runner.reply.unlisted-code", "producer": "ios-runner", - "trigger": "structured runner reply with any other code, e.g. XCTEST_RECORDED_FAILURE", - "dispatched": "yes" + "trigger": "structured runner reply with any other code, e.g. XCTEST_RECORDED_FAILURE, which the runner reports as \"the action may not have been performed\"", + "dispatched": "unknown" }, { "id": "ios-runner.pre-send.session-start-failed", @@ -160,8 +160,8 @@ { "id": "ios-runner.status.failed", "producer": "ios-runner", - "trigger": "transport lost; status probe reports lifecycleState failed", - "dispatched": "yes" + "trigger": "transport lost; status probe reports lifecycleState failed with no code the runner proves pre-gesture", + "dispatched": "unknown" }, { "id": "ios-runner.status.failed-RUNNER_BUSY", @@ -173,7 +173,7 @@ "id": "ios-runner.status.completed-without-retained-reply", "producer": "ios-runner", "trigger": "transport lost; status probe reports lifecycleState completed without a readable retained reply", - "dispatched": "yes" + "dispatched": "unknown" }, { "id": "ios-runner.status.accepted", @@ -226,20 +226,20 @@ { "id": "android-adb.fill.unverified", "producer": "android-adb", - "trigger": "fill typed the text and its verification still failed after the last clear-and-retype pass. A throw from inside the second pass keeps that pass's adb input verdict (unknown with dispatchedSteps), though the first pass already typed", - "dispatched": "yes" + "trigger": "fill sent the text and its verification still failed after the last clear-and-retype pass. A throw from inside the second pass keeps that pass's adb input verdict (unknown with dispatchedSteps), though the first pass already typed", + "dispatched": "unknown" }, { "id": "android-helper.gesture.reported-failure", "producer": "android-helper", - "trigger": "one-shot helper gesture returned a parsed final result with ok=false", - "dispatched": "yes" + "trigger": "one-shot helper gesture returned a parsed final result with ok=false, which carries no count of events injected before the failure", + "dispatched": "unknown" }, { "id": "android-helper.gesture.failed-after-result", "producer": "android-helper", "trigger": "one-shot helper gesture returned a parsed final result and exited non-zero", - "dispatched": "yes" + "dispatched": "unknown" }, { "id": "android-helper.gesture.no-parseable-output", @@ -250,8 +250,8 @@ { "id": "android-helper.gesture-session.reported-failure", "producer": "android-helper", - "trigger": "persistent helper session answered the gesture with ok=false", - "dispatched": "yes" + "trigger": "persistent helper session answered the gesture with ok=false, which carries no count of events injected before the failure", + "dispatched": "unknown" }, { "id": "android-helper.gesture-session.transport-failure", diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index a06c596a7a..8686517d8e 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -152,17 +152,21 @@ without needing a simulator. `contracts/fixtures/dispatch-disclosure.json` is the table for `AppErrorDetails.dispatched` on interaction failures: one row per producer -event, each with the value it must leave. Only a producer that refuses before -dispatch (target resolution, admission, a runner pre-send refusal) may say -`no`, and only one that proved execution may say `yes`. The daemon fills +event, each with the value it must leave. The field has two values: `no` (the +operation provably never reached the device, so a resend is safe) and +`unknown` (it may have landed, so observe before resending). Only a producer +that refuses before dispatch (target resolution, admission, a runner pre-send +refusal) may say `no`; no producer can prove execution on its failure path, so +there is no third value. The daemon fills `unknown` once, around interaction dispatch, for a failure no producer classified, and never overwrites a producer's value: a wrong `no` makes a consumer resend an action that already ran, while a wrong `unknown` only costs an observation. The one exception is a read-only command (registry `recordingEffect: 'observes-app'`): the daemon sets `no` over any producer -value, because a read has no side effect and is always safe to resend. Each row names its driver -file by id prefix, that file drives the real producer, and a row marked -`implementedBy` waits for the branch that ships it. +value, because a read has no side effect and is always safe to resend. Each row +without `implementedBy` names its driver file by id prefix, and that file drives +the real producer; a row marked `implementedBy` waits for the branch that ships +it. For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of diff --git a/packages/contracts/src/dispatch-disclosure.test.ts b/packages/contracts/src/dispatch-disclosure.test.ts index 683ebdf927..b0d7cd256b 100644 --- a/packages/contracts/src/dispatch-disclosure.test.ts +++ b/packages/contracts/src/dispatch-disclosure.test.ts @@ -17,7 +17,7 @@ import { } from './dispatch-disclosure.fixtures.ts'; const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); -const DISPATCH_VALUES: readonly string[] = ['no', 'yes', 'unknown']; +const DISPATCH_VALUES: readonly string[] = ['no', 'unknown']; test('a producer verdict overwrites; the seam verdict fills only an unclassified failure', () => { const error = new AppError('COMMAND_FAILED', 'tap failed', { hint: 'retry' }); diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index 55bce9c75d..130064f3b4 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -76,7 +76,7 @@ test('summarizeCommandAttemptFailures keeps every attempt in the order it ran', }); test('normalizeError keeps a producer dispatch disclosure in details and never invents one', () => { - for (const dispatched of ['no', 'yes', 'unknown'] as const) { + for (const dispatched of ['no', 'unknown'] as const) { const normalized = normalizeError(new AppError('COMMAND_FAILED', 'tap failed', { dispatched })); assert.equal(normalized.details?.dispatched, dispatched); assert.throws( diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 592c53ea80..14332c6bbf 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -57,12 +57,12 @@ export type DiagnosticsRecordRef = { }; /** - * Whether the operation a failed request asked for reached the device: `no` when it provably did - * not, `yes` when a producer proved it executed, `unknown` when neither can be proven. A failure - * without the field was classified by no producer. The producer rows live in + * Whether the operation a failed request asked for reached the device. `no`: it provably never + * did, so resending it is safe. `unknown`: it may have landed, so observe the device before + * resending. A failure without the field was classified by no producer. The producer rows live in * `contracts/fixtures/dispatch-disclosure.json`. */ -export type DispatchDisclosure = 'no' | 'yes' | 'unknown'; +export type DispatchDisclosure = 'no' | 'unknown'; /** The error details bag as it crosses the wire: free-form, with the typed keys a reader may rely on. */ export type ErrorWireDetails = Record & { dispatched?: DispatchDisclosure }; diff --git a/packages/platform-android/src/fill-verification.ts b/packages/platform-android/src/fill-verification.ts index cd4072363d..f496729e8f 100644 --- a/packages/platform-android/src/fill-verification.ts +++ b/packages/platform-android/src/fill-verification.ts @@ -147,7 +147,7 @@ export function completeAndroidFillVerification( androidFillFailureMessage(verification), androidFillFailureDetails(expected, verification), ), - verification ? 'yes' : 'unknown', + 'unknown', ); } diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index 17d503ed28..3a4c51433f 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -3,7 +3,7 @@ * that piggyback on it. Session ownership itself — starting, reusing, retiring — belongs to * `snapshot-helper-session-lifecycle.ts`, which this module acquires through. */ -import { AppError, type DispatchDisclosure, discloseDispatch } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { readAndroidCaptureFailureReason } from '@agent-device/contracts/android-snapshot-quality'; import type { @@ -91,14 +91,18 @@ async function captureFromAndroidSnapshotHelperSession(params: { // `am instrument` run instead. export type AndroidTouchHelperAction = 'gesture' | 'viewport'; -/** Only a gesture injects input, so only its failures say whether input reached the device. */ +/** + * Only a gesture injects input, so only its failures say whether input reached the device. Its + * failure is `unknown` on every path: an `ok=false` result carries the thrown `errorType` but no + * count of events injected before the throw, so a parse refusal and a mid-injection failure read + * alike. + */ export function discloseHelperTouchDispatch( action: AndroidTouchHelperAction, error: unknown, - dispatched: DispatchDisclosure, ): unknown { if (action !== 'gesture' || !(error instanceof AppError)) return error; - return discloseDispatch(error, dispatched); + return discloseDispatch(error, 'unknown'); } export async function runAndroidSnapshotHelperSessionTouchCommand(params: { @@ -143,7 +147,7 @@ export async function runAndroidSnapshotHelperSessionTouchCommand(params: { // Transport-level failure: the session process can no longer be trusted. Stop it so the next // command runs against a fresh helper instead of a wedged socket. await stopAndroidSnapshotHelperSession(params.deviceKey); - throw discloseHelperTouchDispatch(params.action, error, 'unknown'); + throw discloseHelperTouchDispatch(params.action, error); } if (headers.ok !== 'true') { // The helper ran and reported a structured failure; the session itself stays healthy. @@ -154,7 +158,6 @@ export async function runAndroidSnapshotHelperSessionTouchCommand(params: { headers.message || headers.errorType || `Android automation helper ${params.action} failed`, { errorType: headers.errorType, helper: headers }, ), - 'yes', ); } return headers; diff --git a/packages/platform-android/src/touch-helper.ts b/packages/platform-android/src/touch-helper.ts index d53ba4d6c8..5be52a45ad 100644 --- a/packages/platform-android/src/touch-helper.ts +++ b/packages/platform-android/src/touch-helper.ts @@ -290,7 +290,6 @@ async function runOneShotTouchHelper(options: { throw discloseHelperTouchDispatch( options.action, new AppError('COMMAND_FAILED', error.message, error.details, error), - 'yes', ); } if (error.code !== HELPER_NO_FINAL_RESULT) throw error; @@ -305,7 +304,6 @@ async function runOneShotTouchHelper(options: { execFailureDetails(result), error, ), - 'unknown', ); } if (result.exitCode !== 0) { @@ -316,7 +314,6 @@ async function runOneShotTouchHelper(options: { 'Android automation helper failed', execFailureDetails(result, { helper: finalRecord }), ), - 'yes', ); } return options.readResult(finalRecord); diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 827fd27d86..ceea05d042 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -115,7 +115,7 @@ const DRIVERS: Record Promise> = { 'ios-runner.reply.ELEMENT_OFFSCREEN': () => replyFailure('ELEMENT_OFFSCREEN'), 'ios-runner.reply.AMBIGUOUS_MATCH': () => replyFailure('AMBIGUOUS_MATCH'), 'ios-runner.reply.MAIN_THREAD_TIMEOUT': () => replyFailure('MAIN_THREAD_TIMEOUT'), - 'ios-runner.reply.executed-failure': () => replyFailure('XCTEST_RECORDED_FAILURE'), + 'ios-runner.reply.unlisted-code': () => replyFailure('XCTEST_RECORDED_FAILURE'), 'ios-runner.status.failed': () => lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorMessage: 'tap failed' })), 'ios-runner.status.failed-RUNNER_BUSY': () => diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 7989a7eba7..645196db27 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -314,7 +314,7 @@ function handleCompletedRunnerStatus( return { type: 'skipInvalidation', error: transportError, - dispatched: 'yes', + dispatched: 'unknown', reason: 'read_only_completed_without_retained_response', lifecycleState: 'completed', }; @@ -324,7 +324,7 @@ function handleCompletedRunnerStatus( type: 'skipInvalidation', reason: 'completed_without_retained_response', lifecycleState: 'completed', - dispatched: 'yes', + dispatched: 'unknown', error: new AppError( 'COMMAND_FAILED', `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 538a2b8903..6bea8c1cec 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -275,10 +275,10 @@ const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlyMap< ]); /** - * Runner codes whose reply proves something other than "the command executed and failed". The - * refusals answer before the command runs, the selector refusals included: the runner resolves the - * element and refuses before any gesture. `MAIN_THREAD_TIMEOUT` abandons work that may still land. - * Every other structured reply comes from a command the runner executed. + * Runner codes whose reply proves the command never reached the device. The refusals answer before + * the command runs, the selector refusals included: the runner resolves the element and refuses + * before any gesture. Every other code, and a reply without one, is `unknown`: a failure after the + * command started cannot prove whether its gesture landed. */ const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new Map([ ['ELEMENT_NOT_FOUND', 'no'], @@ -290,7 +290,6 @@ const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new [SCROLL_KEYBOARD_OCCLUDES_SURFACE_RUNNER_CODE, 'no'], [ALERT_NOT_FOUND_RUNNER_CODE, 'no'], ...[...RUNNER_SCREEN_CAPTURE_REFUSAL_RUNNER_CODES].map((code) => [code, 'no'] as const), - [MAIN_THREAD_TIMEOUT_RUNNER_CODE, 'unknown'], ]); /** @@ -331,7 +330,7 @@ export function classifyRunnerReportedError( dispatched: (runnerErrorCode === undefined ? undefined - : RUNNER_ERROR_CODE_DISPATCH.get(runnerErrorCode)) ?? 'yes', + : RUNNER_ERROR_CODE_DISPATCH.get(runnerErrorCode)) ?? 'unknown', }), }); } diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 5c8a1d4857..33cec5c952 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -513,8 +513,7 @@ function discloseRestartDispatch( restartedSession: RunnerSession | undefined, ): AppError { if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); - if (firstAttemptUnwritten || error.details?.dispatched === 'yes') return error; - return discloseDispatch(error, 'unknown'); + return firstAttemptUnwritten ? error : discloseDispatch(error, 'unknown'); } async function runPrepareHealthCheck( diff --git a/src/daemon/__tests__/direct-ios-selector.test.ts b/src/daemon/__tests__/direct-ios-selector.test.ts index f2abdfa116..d0795e7743 100644 --- a/src/daemon/__tests__/direct-ios-selector.test.ts +++ b/src/daemon/__tests__/direct-ios-selector.test.ts @@ -70,16 +70,14 @@ test('a pre-send COMMAND_FAILED falls back; the message text never decides', () }); test('a failure that may have tapped never falls back', () => { - for (const dispatched of ['unknown', 'yes'] as const) { - for (const code of ['COMMAND_FAILED', 'ELEMENT_NOT_FOUND'] as const) { - assert.equal( - isDirectIosSelectorFallbackError(new AppError(code, 'failed', { dispatched }), { - delegateSemanticFailures: true, - }), - false, - `${code} ${dispatched}`, - ); - } + for (const code of ['COMMAND_FAILED', 'ELEMENT_NOT_FOUND'] as const) { + assert.equal( + isDirectIosSelectorFallbackError(new AppError(code, 'failed', { dispatched: 'unknown' }), { + delegateSemanticFailures: true, + }), + false, + code, + ); } }); diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index c070312fe8..af9983cf17 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -89,8 +89,8 @@ const RUNNER_SELECTOR_REFUSAL_CODES: ReadonlySet = new Set([ /** * Whether a failed direct iOS selector tap may delegate to the tree path, which taps again. Only a * failure disclosed `dispatched: no` may: a connect refusal, a pre-send restart or readiness - * verdict, `RUNNER_BUSY`, or a runner selector refusal. A failure that is `unknown` or `yes` may - * already have tapped. Selector refusals delegate only when `delegateSemanticFailures` is set; + * verdict, `RUNNER_BUSY`, or a runner selector refusal. An `unknown` failure may already have + * tapped. Selector refusals delegate only when `delegateSemanticFailures` is set; * Maestro replay keeps their runner-native shapes. */ export function isDirectIosSelectorFallbackError( diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 02e5472ef7..53a767afb2 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -103,7 +103,13 @@ async function pressThatLeftTheApp(): Promise { readAppState: async () => ({ package: 'com.android.settings' }), isPermissionPackage: async () => false, } as unknown as AndroidObservationAdapter; - return await assertAndroidPressStayedInApp(session, '@e1', observation); + return await discloseInteractionDispatch( + { token: 't', session: session.name, command: 'press', positionals: ['@e1'] }, + async () => { + await assertAndroidPressStayedInApp(session, '@e1', observation); + return null; + }, + ); } const DRIVERS: Record Promise> = { @@ -146,7 +152,7 @@ for (const row of ROWS) { } test('the daemon keeps a producer verdict instead of inferring its own', async () => { - for (const dispatched of ['no', 'yes'] satisfies DispatchDisclosure[]) { + for (const dispatched of ['no', 'unknown'] satisfies DispatchDisclosure[]) { await assert.rejects(pressAfterUnclassifiedTouchFailure({ dispatched }), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.dispatched, dispatched); @@ -158,21 +164,19 @@ test('the daemon keeps a producer verdict instead of inferring its own', async ( test('a read-only command discloses no over a producer verdict', async () => { const capture = vi.mocked(captureSnapshotWithInteractor); capture.mockClear(); - for (const dispatched of ['unknown', 'yes'] satisfies DispatchDisclosure[]) { - capture.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'runner capture lost', { dispatched }), - ); - await assert.rejects( - press({ command: 'get', positionals: ['text', 'label="Missing"'] }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'runner capture lost'); - assert.equal(error.details?.dispatched, 'no'); - return true; - }, - ); - } - assert.equal(capture.mock.calls.length, 2); + capture.mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'runner capture lost', { dispatched: 'unknown' }), + ); + await assert.rejects( + press({ command: 'get', positionals: ['text', 'label="Missing"'] }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'runner capture lost'); + assert.equal(error.details?.dispatched, 'no'); + return true; + }, + ); + assert.equal(capture.mock.calls.length, 1); }); test('a read-only command discloses no over a producer verdict it throws', async () => { diff --git a/src/daemon/interaction/internal/interaction-android-escape.ts b/src/daemon/interaction/internal/interaction-android-escape.ts index e76c6a2c35..4f923396be 100644 --- a/src/daemon/interaction/internal/interaction-android-escape.ts +++ b/src/daemon/interaction/internal/interaction-android-escape.ts @@ -24,7 +24,7 @@ export async function assertAndroidPressStayedInApp( throw new AppError( 'COMMAND_FAILED', `press ${targetLabel} left ${session.appBundleId} and foregrounded ${surface.foregroundPackage}. The tap likely escaped the app.`, - { ...surface, dispatched: 'yes' }, + surface, ); } diff --git a/src/daemon/scroll-movement.ts b/src/daemon/scroll-movement.ts index 2e3af24444..2f0d124614 100644 --- a/src/daemon/scroll-movement.ts +++ b/src/daemon/scroll-movement.ts @@ -593,7 +593,7 @@ function scrollNoProgressError( }), }, ); - return discloseDispatch(error, 'yes'); + return discloseDispatch(error, 'unknown'); } /** diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index e73b23b98e..9265e32296 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -234,7 +234,7 @@ Use `client.command.()` for command-level device actions. It uses the sa Results are daemon-shaped objects with typed known fields, so command semantics stay aligned with the CLI. -A failed interaction rejects with the same error the CLI prints. Read `error.details.dispatched` before you retry; [Commands](./commands.md) explains the three values. +A failed interaction rejects with the same error the CLI prints. Read `error.details.dispatched` before you retry; [Commands](./commands.md) explains the two values. ```ts await client.command.wait({ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b3d2674054..18d2a46f74 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -492,10 +492,9 @@ agent-device gesture transform 200 420 80 -40 2 35 700 # combined pan, zoom, and When an interaction fails, read `error.details.dispatched` before you retry: - `no`: the action never reached the device. Retry it as it is. -- `yes`: the action ran on the device and failed after that. Take a snapshot and decide from what you see. - `unknown`: the action may have landed. Take a snapshot before you retry; a blind retry can tap, type, or navigate twice. -A read-only command such as `get` always reports `no`: it changes nothing, so a retry is safe. +An interaction on a read-only command such as `get` reports `no`: it changes nothing, so a retry is safe. A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. From ae3eb02ebe1016ba1de7cef8cb540f52b6ea6b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 15:55:23 +0200 Subject: [PATCH 12/42] fix(errors): a later step's refusal never claims no for the whole series `dispatched: no` describes the whole requested operation. A series that issues more than one device-reaching step (press --count split into runner sequence chunks, swipe --count, adb text chunks) let a later step's `no` (RUNNER_BUSY on chunk 2, a refused connect after the runner crashed) stand for the whole interaction, so a consumer would resend the series and the app would get extra taps. One kernel helper owns the rule: `discloseDispatchAfterSteps(error, dispatchedSteps)` keeps `no` only while no step was dispatched, and otherwise stamps `unknown` with `details.dispatchedSteps` (added to any count the failing step carries, so nested series compose). The local Android copy is gone; `discloseAdbInputDispatch` now classifies one input. Series loops, enumerated by git grep -n -A22 -E '^[[:space:]]*(for|while) \(' -- packages src \ | LC_ALL=C grep -a -E '^(packages/[^/]+/src|src)/' \ | LC_ALL=C grep -a -v -E '\.test\.ts|__tests__' \ | LC_ALL=C grep -a -E 'await (.*[^a-zA-Z])?(runCommand|interactor\.(tap|longPress)|doubleTap\.call|interactions\.gesture|runAndroidShell|runAdbShell|sendAndroidImeHelperText|clearAndroidImeHelperText|typeAndroidShellChunk|focusAndroid|scroll|runStep|runJson|typeText|runAlert)\(' and each hit that is an interaction series now applies the helper: - platform-apple runner-sequence.ts runApplePressSeries (sequence chunks) - contracts touch-runtime.ts executeGenericPress (press --count on Android, Linux, and every generic interactor) - daemon interaction-gesture.ts runSwipeRepetitions (swipe --count) - platform-android text-input.ts: fillAndroid (focus tap, then a pass), fillAndroidImeHelper, typeAndroidImeHelper, typeAndroidShell, clearFocusedText - platform-android device-input-state.ts keyboard dismiss keyevents - capture-kit scroll-edge-state.ts and daemon scroll-until.ts scroll passes - maestro daemon-runtime-port-observation.ts scrollUntilTypedMaestroTarget - platform-linux runPacedScrollSteps, platform-web runPacedScroll, provider-limrun per-character typeText Left as is: platform-apple alert.ts actOnAppleAlert retries only after an ALERT_NOT_FOUND refusal (`no`), so no earlier attempt dispatched; alert.ts 84 is a read poll. The other hits (app lifecycle, settings, permissions, notifications, perf, macOS host provider, ime-helper broadcast extras) are not interactions or are calls after the loop. WebDriver actions land with their own package branch. Table rows and drivers: ios-runner.series.later-chunk-refused (press --count 25 over the real send stack; chunk 2 answers RUNNER_BUSY -> unknown, dispatchedSteps 1) and daemon.series.swipe-later-repetition- refused (swipe --count 2 through the daemon handler; repetition 2 fails `no` -> unknown, dispatchedSteps 1). Mutations: `throw error` instead of the helper in runApplePressSeries fails ios-runner.series.later-chunk-refused; the same in runSwipeRepetitions fails daemon.series.swipe-later-repetition-refused; dropping the `dispatchedSteps === 0` guard in the helper fails its kernel test. --- contracts/fixtures/dispatch-disclosure.json | 12 ++ .../src/snapshot/scroll-edge-state.ts | 46 +++--- packages/contracts/src/touch-runtime.ts | 28 ++-- packages/kernel/src/errors.test.ts | 19 +++ packages/kernel/src/errors.ts | 15 ++ .../daemon-runtime-port-observation.ts | 58 ++++---- .../src/device-input-state.ts | 16 ++- .../platform-android/src/input-actions.ts | 14 +- packages/platform-android/src/text-input.ts | 131 +++++++++++------- .../runner-dispatch-disclosure.test.ts | 29 ++++ .../src/runner/runner-sequence.ts | 16 ++- packages/platform-linux/src/input-actions.ts | 13 +- .../src/agent-browser-provider.ts | 14 +- packages/provider-limrun/src/ios.ts | 14 +- .../interaction-dispatch-disclosure.test.ts | 40 ++++++ .../internal/interaction-gesture.ts | 24 ++-- src/daemon/scroll-until.ts | 36 ++--- 17 files changed, 364 insertions(+), 161 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index c930c7c175..dcc0a9ba93 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -35,6 +35,12 @@ "trigger": "scroll_no_progress: the scroll gesture was sent and the container, still hiding content, never shifted; nothing proves whether the gesture landed", "dispatched": "unknown" }, + { + "id": "daemon.series.swipe-later-repetition-refused", + "producer": "daemon", + "trigger": "swipe --count 2: the first repetition ran and the second failed with a producer verdict of no; the series reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, { "id": "maestro-direct.fallback.pre-send-refusal", "producer": "daemon", @@ -133,6 +139,12 @@ "trigger": "structured runner reply with any other code, e.g. XCTEST_RECORDED_FAILURE, which the runner reports as \"the action may not have been performed\"", "dispatched": "unknown" }, + { + "id": "ios-runner.series.later-chunk-refused", + "producer": "ios-runner", + "trigger": "press --count 25 splits into two sequence chunks; the first ran and the second was refused with RUNNER_BUSY; the series reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, { "id": "ios-runner.pre-send.session-start-failed", "producer": "ios-runner", diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state.ts b/packages/capture-kit/src/snapshot/scroll-edge-state.ts index 240c1237f6..3630b99d2b 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type { ScrollMovementObservation } from '@agent-device/contracts/scroll-command'; import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; import type { Point, RawSnapshotNode, Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; @@ -165,29 +165,33 @@ export async function runScrollEdgePasses(params: { let result: TResult | undefined; const recentSignatures: string[] = []; pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW); - while (state.canScroll) { - if (passes >= SCROLL_EDGE_PASS_LIMIT) { - throw new AppError( - 'COMMAND_FAILED', - `scroll ${edge} reached the safety limit before the snapshot showed the edge`, - { - reason: 'scroll_edge_pass_limit', - edge, - passes, - hint: 'The scoped scroll container still reports hidden content. Run scroll --until to stop on the element you are after, or snapshot -i to inspect the current state.', - }, - ); - } + try { + while (state.canScroll) { + if (passes >= SCROLL_EDGE_PASS_LIMIT) { + throw new AppError( + 'COMMAND_FAILED', + `scroll ${edge} reached the safety limit before the snapshot showed the edge`, + { + reason: 'scroll_edge_pass_limit', + edge, + passes, + hint: 'The scoped scroll container still reports hidden content. Run scroll --until to stop on the element you are after, or snapshot -i to inspect the current state.', + }, + ); + } - result = await scroll(); - passes += 1; - await settleAfterPass(); - state = await captureState(state.scope); + result = await scroll(); + passes += 1; + await settleAfterPass(); + state = await captureState(state.scope); - pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW); - if (state.canScroll && scrollSurfaceIsStuck(recentSignatures)) { - throw buildScrollEdgeNoProgressError(edge, passes); + pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW); + if (state.canScroll && scrollSurfaceIsStuck(recentSignatures)) { + throw buildScrollEdgeNoProgressError(edge, passes); + } } + } catch (error) { + throw discloseDispatchAfterSteps(error, passes); } return { passes, result }; diff --git a/packages/contracts/src/touch-runtime.ts b/packages/contracts/src/touch-runtime.ts index 600dcaf281..30ffd6e5a0 100644 --- a/packages/contracts/src/touch-runtime.ts +++ b/packages/contracts/src/touch-runtime.ts @@ -1,5 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type { Point } from '@agent-device/kernel/snapshot'; import { localInteractorSource, @@ -264,17 +264,23 @@ async function executeGenericPress( } const doubleTap = options.doubleTap ? requireDoubleTapMechanic(interactor) : undefined; let first: Record | void = undefined; - for (let index = 0; index < options.count; index += 1) { - const [dx, dy] = pressJitter(index, options.jitterPx); - const result = doubleTap - ? await doubleTap.call(interactor, point.x + dx, point.y + dy) - : options.holdMs > 0 - ? await interactor.longPress(point.x + dx, point.y + dy, options.holdMs) - : await interactor.tap(point.x + dx, point.y + dy); - first ??= result; - if (index < options.count - 1 && options.intervalMs > 0) { - await pause(options.intervalMs); + let dispatchedPresses = 0; + try { + for (let index = 0; index < options.count; index += 1) { + const [dx, dy] = pressJitter(index, options.jitterPx); + const result = doubleTap + ? await doubleTap.call(interactor, point.x + dx, point.y + dy) + : options.holdMs > 0 + ? await interactor.longPress(point.x + dx, point.y + dy, options.holdMs) + : await interactor.tap(point.x + dx, point.y + dy); + dispatchedPresses += 1; + first ??= result; + if (index < options.count - 1 && options.intervalMs > 0) { + await pause(options.intervalMs); + } } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedPresses); } return first; } diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index 130064f3b4..dfbdf084fd 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError, + discloseDispatch, + discloseDispatchAfterSteps, normalizeError, throwDaemonError, readElementMatchCandidateRefs, @@ -89,3 +91,20 @@ test('normalizeError keeps a producer dispatch disclosure in details and never i assert.equal('dispatched' in (unclassified.details ?? {}), false); assert.equal(normalizeError(new AppError('DEVICE_IN_USE', 'busy')).details, undefined); }); + +test('discloseDispatchAfterSteps keeps no only while no step of the series was dispatched', () => { + const refusal = () => discloseDispatch(new AppError('COMMAND_FAILED', 'busy'), 'no'); + assert.equal((discloseDispatchAfterSteps(refusal(), 0) as AppError).details?.dispatched, 'no'); + const later = discloseDispatchAfterSteps(refusal(), 2) as AppError; + assert.equal(later.details?.dispatched, 'unknown'); + assert.equal(later.details?.dispatchedSteps, 2); + const nested = discloseDispatchAfterSteps( + discloseDispatch(new AppError('COMMAND_FAILED', 'chunk 3 lost'), 'unknown', { + dispatchedSteps: 2, + }), + 1, + ) as AppError; + assert.equal(nested.details?.dispatchedSteps, 3); + const plain = new Error('socket closed'); + assert.equal(discloseDispatchAfterSteps(plain, 4), plain); +}); diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 14332c6bbf..697ada3324 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -565,6 +565,21 @@ export function discloseDispatch( return error; } +/** + * The failure of a series that issues more than one device-reaching step, after `dispatchedSteps` + * of them reached the device. `no` describes the whole requested operation, so it holds only while + * no step was dispatched; after that the failure is `unknown`, and `details.dispatchedSteps` adds + * this series' count to any count the failing step already carries. A failure that is not an + * {@link AppError} passes through unchanged for the boundary that normalizes it. + */ +export function discloseDispatchAfterSteps(error: unknown, dispatchedSteps: number): unknown { + if (dispatchedSteps === 0 || !(error instanceof AppError)) return error; + const innerSteps = error.details?.dispatchedSteps; + return discloseDispatch(error, 'unknown', { + dispatchedSteps: dispatchedSteps + (typeof innerSteps === 'number' ? innerSteps : 0), + }); +} + /** The side-effect seam's verdict, recorded only when no producer classified the failure. */ export function discloseUnclassifiedDispatch( error: Failure, diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts b/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts index f6ce5ffd6d..5e48897d3b 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts @@ -1,4 +1,8 @@ -import { createRequestCanceledError, AppError } from '@agent-device/kernel/errors'; +import { + createRequestCanceledError, + AppError, + discloseDispatchAfterSteps, +} from '@agent-device/kernel/errors'; import { createHash } from 'node:crypto'; import { literalFromMaestroRegex, @@ -188,33 +192,39 @@ export async function scrollUntilTypedMaestroTarget(params: { const deadline = params.dependencies.now() + params.timeoutMs; let lastMatch: MaestroTargetMatch | undefined; let settledSnapshot: SnapshotState | undefined; + let dispatchedScrolls = 0; - while (true) { - throwIfAborted(params.context.signal); - const snapshot = settledSnapshot ?? (await captureRetriableMaestroSnapshot(params, deadline)); - settledSnapshot = undefined; - lastMatch = resolveTargetFromSnapshot({ - query: { selector: params.selector }, - context: params.context, - snapshot, - platform: params.platform, - mode: 'observe', - }); - if ( - lastMatch.visiblePercentage === MAESTRO_RUNTIME_ADAPTER_POLICY.scrollUntilVisiblePercentage - ) { - return lastMatch; + try { + while (true) { + throwIfAborted(params.context.signal); + const snapshot = settledSnapshot ?? (await captureRetriableMaestroSnapshot(params, deadline)); + settledSnapshot = undefined; + lastMatch = resolveTargetFromSnapshot({ + query: { selector: params.selector }, + context: params.context, + snapshot, + platform: params.platform, + mode: 'observe', + }); + if ( + lastMatch.visiblePercentage === MAESTRO_RUNTIME_ADAPTER_POLICY.scrollUntilVisiblePercentage + ) { + return lastMatch; + } + if (params.dependencies.now() >= deadline) break; + + const remaining = deadline - params.dependencies.now(); + if (remaining > 0) { + settledSnapshot = await params.scroll(remaining, snapshot); + dispatchedScrolls += 1; + } } - if (params.dependencies.now() >= deadline) break; - const remaining = deadline - params.dependencies.now(); - if (remaining > 0) { - settledSnapshot = await params.scroll(remaining, snapshot); - } + throwIfAborted(params.context.signal); + return requireObservationResult(lastMatch); + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedScrolls); } - - throwIfAborted(params.context.signal); - return requireObservationResult(lastMatch); } export async function waitForTypedSnapshotStability(params: { diff --git a/packages/platform-android/src/device-input-state.ts b/packages/platform-android/src/device-input-state.ts index 6409d8686d..cca08b2059 100644 --- a/packages/platform-android/src/device-input-state.ts +++ b/packages/platform-android/src/device-input-state.ts @@ -1,7 +1,7 @@ import type { ShellWord } from '@agent-device/kernel/device-shell'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import { sleep } from './adb.ts'; import { @@ -110,11 +110,15 @@ export async function dismissAndroidKeyboardWithAdb( let state = initialState; let attempts = 0; - while (state.visible && attempts < ANDROID_KEYBOARD_DISMISS_MAX_ATTEMPTS) { - await runAdbShell(adb, ['input', 'keyevent', ANDROID_KEYCODE_ESCAPE]); - attempts += 1; - await sleep(ANDROID_KEYBOARD_DISMISS_RETRY_DELAY_MS); - state = await getAndroidKeyboardStatusWithAdb(adb); + try { + while (state.visible && attempts < ANDROID_KEYBOARD_DISMISS_MAX_ATTEMPTS) { + await runAdbShell(adb, ['input', 'keyevent', ANDROID_KEYCODE_ESCAPE]); + attempts += 1; + await sleep(ANDROID_KEYBOARD_DISMISS_RETRY_DELAY_MS); + state = await getAndroidKeyboardStatusWithAdb(adb); + } + } catch (error) { + throw discloseDispatchAfterSteps(error, attempts); } if (initialState.visible && state.visible) { diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index 8c01f23ad9..751da37ca7 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -32,18 +32,10 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr } } -/** - * An `adb shell input` failure after `dispatchedSteps` earlier inputs succeeded: adb that never - * started delivered nothing; once any input ran, the event may have reached the device. - */ -export function discloseAdbInputDispatch(error: unknown, dispatchedSteps = 0): unknown { +/** One `adb shell input` failure: adb that never started delivered nothing; otherwise it may have. */ +export function discloseAdbInputDispatch(error: unknown): unknown { if (!(error instanceof AppError)) return error; - const neverStarted = error.code === 'TOOL_MISSING' && dispatchedSteps === 0; - return discloseDispatch( - error, - neverStarted ? 'no' : 'unknown', - dispatchedSteps > 0 ? { dispatchedSteps } : {}, - ); + return discloseDispatch(error, error.code === 'TOOL_MISSING' ? 'no' : 'unknown'); } export async function pressAndroidTvRemote( diff --git a/packages/platform-android/src/text-input.ts b/packages/platform-android/src/text-input.ts index 7915bdcba2..80de2dc0ff 100644 --- a/packages/platform-android/src/text-input.ts +++ b/packages/platform-android/src/text-input.ts @@ -6,7 +6,7 @@ */ import type { FillUnconfirmedVerification } from '@agent-device/contracts/fill-evidence'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { @@ -82,34 +82,39 @@ export async function fillAndroid( return completeAndroidFillVerification(text, beforeTarget, verification); } let lastVerification: AndroidFillVerification | null = null; - - for (const attempt of buildAndroidShellFillAttempts(delayMs)) { - await focusAndroid(device, x, y); - const channel = await admitAndroidTextChannel(device, 'fill', text); - if (channel.backend === 'test-ime') { - const verification = await fillAndroidImeHelper( + let dispatchedSteps = 0; + try { + for (const attempt of buildAndroidShellFillAttempts(delayMs)) { + await focusAndroid(device, x, y); + dispatchedSteps += 1; + const channel = await admitAndroidTextChannel(device, 'fill', text); + if (channel.backend === 'test-ime') { + const verification = await fillAndroidImeHelper( + device, + channel.packageName, + x, + y, + text, + beforeTarget, + helper, + ); + return completeAndroidFillVerification(text, beforeTarget, verification); + } + const verification = await runAndroidShellFillAttempt( device, - channel.packageName, - x, - y, - text, - beforeTarget, + { x, y, text, beforeTarget, attempt }, helper, ); - return completeAndroidFillVerification(text, beforeTarget, verification); - } - const verification = await runAndroidShellFillAttempt( - device, - { x, y, text, beforeTarget, attempt }, - helper, - ); - lastVerification = verification; - if (verification.ok) return; - if (verification.reason === 'ime_capture') { - return completeAndroidFillVerification(text, beforeTarget, verification); + lastVerification = verification; + if (verification.ok) return; + if (verification.reason === 'ime_capture') { + return completeAndroidFillVerification(text, beforeTarget, verification); + } + const unconfirmed = buildAndroidFillUnconfirmedVerification(text, beforeTarget, verification); + if (unconfirmed) return unconfirmed; } - const unconfirmed = buildAndroidFillUnconfirmedVerification(text, beforeTarget, verification); - if (unconfirmed) return unconfirmed; + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSteps); } return completeAndroidFillVerification(text, beforeTarget, lastVerification); @@ -223,17 +228,26 @@ async function typeAndroidImeHelper( ): Promise { const adb = resolveAndroidAdbExecutor(device); const parts = text.split('\n'); - for (const [partIndex, part] of parts.entries()) { - const chunks = delayMs > 0 ? chunkAndroidInputText(part, 1) : [part]; - for (const [chunkIndex, chunk] of chunks.entries()) { - if (chunk) await sendAndroidImeHelperText(adb, packageName, chunk); - if (delayMs > 0 && (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length)) { - await sleep(delayMs); + let dispatchedSteps = 0; + try { + for (const [partIndex, part] of parts.entries()) { + const chunks = delayMs > 0 ? chunkAndroidInputText(part, 1) : [part]; + for (const [chunkIndex, chunk] of chunks.entries()) { + if (chunk) { + await sendAndroidImeHelperText(adb, packageName, chunk); + dispatchedSteps += 1; + } + if (delayMs > 0 && (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length)) { + await sleep(delayMs); + } + } + if (partIndex + 1 < parts.length) { + await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); + dispatchedSteps += 1; } } - if (partIndex + 1 < parts.length) { - await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); - } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSteps); } emitAndroidTextDiagnostic('type', 'test-ime', text); } @@ -249,16 +263,28 @@ async function fillAndroidImeHelper( ): Promise { const adb = resolveAndroidAdbExecutor(device); let lastVerification: AndroidFillVerification | null = null; + let dispatchedSteps = 0; // The caller focused the target while resolving the channel; the retry re-focuses because it // covers the rare not-yet-bound InputConnection right after focus. - for (let attempt = 0; attempt < 2; attempt += 1) { - if (attempt > 0) await focusAndroid(device, x, y); - await clearAndroidImeHelperText(adb, packageName); - if (text) await sendAndroidImeHelperText(adb, packageName, text); - const verification = await verifyAndroidFilledText(device, x, y, text, helper); - lastVerification = verification; - if (verification.ok) break; - if (buildAndroidFillUnconfirmedVerification(text, beforeTarget, verification)) break; + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + if (attempt > 0) { + await focusAndroid(device, x, y); + dispatchedSteps += 1; + } + await clearAndroidImeHelperText(adb, packageName); + dispatchedSteps += 1; + if (text) { + await sendAndroidImeHelperText(adb, packageName, text); + dispatchedSteps += 1; + } + const verification = await verifyAndroidFilledText(device, x, y, text, helper); + lastVerification = verification; + if (verification.ok) break; + if (buildAndroidFillUnconfirmedVerification(text, beforeTarget, verification)) break; + } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSteps); } emitAndroidTextDiagnostic('fill', 'test-ime', text); return lastVerification as AndroidFillVerification; @@ -289,7 +315,7 @@ async function typeAndroidShell( } } } catch (error) { - throw discloseAdbInputDispatch(error, dispatchedSteps); + throw discloseDispatchAfterSteps(discloseAdbInputDispatch(error), dispatchedSteps); } emitAndroidTextDiagnostic(options.action, 'adb-shell', options.text); } @@ -308,15 +334,22 @@ async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise< async function clearFocusedText(device: DeviceInfo, count: number): Promise { const deletes = Math.max(0, count); - await runAndroidShell(device, ['input', 'keyevent', 'KEYCODE_MOVE_END'], { - allowFailure: true, - }); const batchSize = 24; - for (let i = 0; i < deletes; i += batchSize) { - const size = Math.min(batchSize, deletes - i); - await runAndroidShell(device, ['input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')], { + let dispatchedSteps = 0; + try { + await runAndroidShell(device, ['input', 'keyevent', 'KEYCODE_MOVE_END'], { allowFailure: true, }); + dispatchedSteps += 1; + for (let i = 0; i < deletes; i += batchSize) { + const size = Math.min(batchSize, deletes - i); + await runAndroidShell(device, ['input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')], { + allowFailure: true, + }); + dispatchedSteps += 1; + } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSteps); } } diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index ceea05d042..7061471abc 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -15,6 +15,7 @@ import { isRetryableRunnerError, isStructuredRunnerFailure, } from '../runner-error-classification.ts'; +import { runApplePressSeries } from '../runner-sequence.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from '../runner-session.ts'; import { RunnerCommandAccounting } from '../runner-session-types.ts'; import { @@ -95,6 +96,33 @@ async function lostResponse( }); } +/** `press --count 25` over the real send stack: chunk one (20 taps) runs, chunk two is refused. */ +async function pressSeriesRefusedOnSecondChunk(): Promise { + const firstChunk = Array.from({ length: 20 }, () => ({ ok: true, kind: 'tap' })); + server = await startFakeRunnerServer({ + sequence: [ + { kind: 'ok', data: { completedSteps: 20, sequenceResults: firstChunk } }, + { kind: 'runnerError', code: 'RUNNER_BUSY', message: 'runner busy' }, + ], + }); + const session = runnerSession(server.port); + try { + return await runApplePressSeries( + IOS_SIMULATOR, + { x: 10, y: 20 }, + { button: 'primary', count: 25, intervalMs: 0, holdMs: 0, jitterPx: 0, doubleTap: false }, + undefined, + async (command) => + await executeRunnerCommandWithSession(IOS_SIMULATOR, session, command, undefined, 5_000), + ); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatchedSteps, 1); + assert.equal(server.requests.filter((request) => request.command === 'sequence').length, 2); + throw error; + } +} + function statusReply(data: Record): FakeRunnerResponse[] { return [{ kind: 'ok', data }]; } @@ -116,6 +144,7 @@ const DRIVERS: Record Promise> = { 'ios-runner.reply.AMBIGUOUS_MATCH': () => replyFailure('AMBIGUOUS_MATCH'), 'ios-runner.reply.MAIN_THREAD_TIMEOUT': () => replyFailure('MAIN_THREAD_TIMEOUT'), 'ios-runner.reply.unlisted-code': () => replyFailure('XCTEST_RECORDED_FAILURE'), + 'ios-runner.series.later-chunk-refused': pressSeriesRefusedOnSecondChunk, 'ios-runner.status.failed': () => lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorMessage: 'tap failed' })), 'ios-runner.status.failed-RUNNER_BUSY': () => diff --git a/packages/platform-apple/src/runner/runner-sequence.ts b/packages/platform-apple/src/runner/runner-sequence.ts index 773b005907..2a7c503437 100644 --- a/packages/platform-apple/src/runner/runner-sequence.ts +++ b/packages/platform-apple/src/runner/runner-sequence.ts @@ -1,7 +1,7 @@ import type { PressPointOptions } from '@agent-device/contracts/interactor-types'; import { pressJitter } from '@agent-device/contracts/touch-runtime'; import { runnerSynthesizesTap, type DeviceInfo } from '@agent-device/kernel/device'; -import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps, toAppErrorCode } from '@agent-device/kernel/errors'; import type { RunnerCommand, RunnerSequenceStep } from './runner-contract.ts'; export const SEQUENCEABLE_RUNNER_STEP_KINDS = ['tap', 'doubleTap', 'longPress'] as const; @@ -156,8 +156,15 @@ export async function runApplePressSeries( let completedSteps = 0; const sequenceResults: unknown[] = []; let stepOffset = 0; + let dispatchedChunks = 0; for (const chunk of chunks) { - const result = await runCommand(buildRunnerSequenceCommand(chunk, appBundleId)); + let result: Record; + try { + result = await runCommand(buildRunnerSequenceCommand(chunk, appBundleId)); + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedChunks); + } + dispatchedChunks += 1; first ??= result; last = result; let parsed; @@ -165,7 +172,10 @@ export async function runApplePressSeries( parsed = parseRunnerSequenceResult(result); } catch (error) { // The runner reports an index local to its chunk; callers need the global series index. - throw remapSequenceErrorStepIndex(error, stepOffset); + throw discloseDispatchAfterSteps( + remapSequenceErrorStepIndex(error, stepOffset), + dispatchedChunks, + ); } completedSteps += parsed.completedSteps; sequenceResults.push(...parsed.results); diff --git a/packages/platform-linux/src/input-actions.ts b/packages/platform-linux/src/input-actions.ts index 8fe33c4b35..a15aba42f8 100644 --- a/packages/platform-linux/src/input-actions.ts +++ b/packages/platform-linux/src/input-actions.ts @@ -1,5 +1,6 @@ import { ensureInputTool } from './linux-env.ts'; import { resolveLinuxToolProvider, type LinuxPointerButton } from './tool-provider.ts'; +import { discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import { sleep } from '@agent-device/host-kit/retry'; import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; import { DEFAULT_SCROLL_AMOUNT } from '@agent-device/contracts/scroll-gesture'; @@ -270,9 +271,15 @@ async function runPacedScrollSteps( } const intervalMs = durationMs / Math.max(1, totalCount - 1); - for (let index = 0; index < totalCount; index += 1) { - await runStep(1); - if (index < totalCount - 1) await sleep(intervalMs); + let dispatchedSteps = 0; + try { + for (let index = 0; index < totalCount; index += 1) { + await runStep(1); + dispatchedSteps += 1; + if (index < totalCount - 1) await sleep(intervalMs); + } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSteps); } } diff --git a/packages/platform-web/src/agent-browser-provider.ts b/packages/platform-web/src/agent-browser-provider.ts index a80f9045c3..0485d50b1c 100644 --- a/packages/platform-web/src/agent-browser-provider.ts +++ b/packages/platform-web/src/agent-browser-provider.ts @@ -2,7 +2,7 @@ import { execFailureDetails } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { hostPlatform, type OwnedProcessRecordStore } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { @@ -127,9 +127,15 @@ async function runPacedScroll( scrollOptions: { amount?: number; pixels?: number; durationMs?: number } | undefined, ): Promise { const steps = buildPacedScrollSteps(resolveWebScrollDistance(scrollOptions)); - for (const step of steps) { - await runJson(buildScrollArgs(direction, step.distance)); - if (step.delayAfterMs > 0) await sleep(step.delayAfterMs); + let dispatchedSteps = 0; + try { + for (const step of steps) { + await runJson(buildScrollArgs(direction, step.distance)); + dispatchedSteps += 1; + if (step.delayAfterMs > 0) await sleep(step.delayAfterMs); + } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSteps); } } diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 6a76aa9d20..1edbd873c3 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -7,7 +7,7 @@ import type { } from '@agent-device/contracts/device'; import type { FillBackendResult, Interactor } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type Limrun from '@limrun/api'; import { createInstanceClient as createIosInstanceClient, @@ -286,9 +286,15 @@ class LimrunIosInteractor implements Interactor { */ private async enterText(text: string, delayMs?: number): Promise { if (delayMs && delayMs > 0) { - for (const char of Array.from(text)) { - await this.session.client.typeText(char, false, { requireFocus: false }); - await sleep(delayMs); + let dispatchedChars = 0; + try { + for (const char of Array.from(text)) { + await this.session.client.typeText(char, false, { requireFocus: false }); + dispatchedChars += 1; + await sleep(delayMs); + } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedChars); } return; } diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 53a767afb2..55adcfebd2 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -20,6 +20,7 @@ import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capt import { discloseInteractionDispatch } from '../interaction-dispatch-disclosure.ts'; import { handleInteractionCommands } from '../../index.ts'; import { assertAndroidPressStayedInApp } from '../interaction-android-escape.ts'; +import { gestureRuntimeBindingsFixture } from './gesture-runtime-bindings.fixtures.ts'; import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; // contracts/fixtures/dispatch-disclosure.json, daemon and post-action guard rows: the daemon rows @@ -112,6 +113,44 @@ async function pressThatLeftTheApp(): Promise { ); } +/** `swipe --count 2` whose first repetition runs and whose second is refused before dispatch. */ +async function swipeRefusedOnSecondRepetition(): Promise { + const gestures = gestureRuntimeBindingsFixture(); + for (const plan of [gestures.performGesturePlan, gestures.performDirectionalFlingPlan]) { + plan + .mockResolvedValueOnce({}) + .mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'runner busy', { reason: 'runner_busy', dispatched: 'no' }), + ); + } + const sessionStore = makeSessionStore(); + const session = makeSession('dispatch-disclosure-swipe'); + sessionStore.set(session.name, session); + const response = await handleInteractionCommands({ + req: { + token: 't', + session: session.name, + command: 'swipe', + positionals: [], + flags: {}, + input: { from: { x: 100, y: 600 }, to: { x: 100, y: 200 }, count: 2 }, + }, + sessionName: session.name, + sessionStore, + contextFromFlags, + inspectFacts: gestures.inspectFacts, + bindDevice: gestures.bindDevice, + }); + assert.ok(response && !response.ok, 'expected the swipe series to fail'); + assert.equal( + gestures.performGesturePlan.mock.calls.length + + gestures.performDirectionalFlingPlan.mock.calls.length, + 2, + ); + assert.equal(response.error.details?.dispatchedSteps, 1); + throw new AppError(response.error.code, response.error.message, response.error.details); +} + const DRIVERS: Record Promise> = { 'daemon.refusal.ref-not-found': () => refusedPress(['@e9']), 'daemon.refusal.admission': () => refusedPress([]), @@ -125,6 +164,7 @@ const DRIVERS: Record Promise> = { store.set(name, { ...current }); }, }), + 'daemon.series.swipe-later-repetition-refused': swipeRefusedOnSecondRepetition, 'daemon.read-only-command': () => press({ command: 'get', positionals: ['text', 'label="Missing"'] }), 'post-action-guard.android-press-left-app': pressThatLeftTheApp, diff --git a/src/daemon/interaction/internal/interaction-gesture.ts b/src/daemon/interaction/internal/interaction-gesture.ts index 7e4192bc89..2f5218d4cc 100644 --- a/src/daemon/interaction/internal/interaction-gesture.ts +++ b/src/daemon/interaction/internal/interaction-gesture.ts @@ -17,7 +17,7 @@ import { SWIPE_REPETITION_MAX, SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS, } from '@agent-device/contracts/scroll-gesture'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps, normalizeError } from '@agent-device/kernel/errors'; import { REF_GRAMMAR_HINT, splitRefGenerationSuffix, @@ -374,14 +374,20 @@ async function runSwipeRepetitions( pattern: 'one-way' | 'ping-pong', ) { let result: Awaited> | undefined; - for (let index = 0; index < count; index += 1) { - const normalized = normalizePublicSwipeMotion(swipeMotionAtIndex(input, pattern, index)); - result = await runtime.interactions.gesture({ - session: params.sessionName, - requestId: params.req.meta?.requestId, - gesture: normalized.gesture, - }); - if (pauseMs > 0 && index + 1 < count) await sleep(pauseMs); + let dispatchedSwipes = 0; + try { + for (let index = 0; index < count; index += 1) { + const normalized = normalizePublicSwipeMotion(swipeMotionAtIndex(input, pattern, index)); + result = await runtime.interactions.gesture({ + session: params.sessionName, + requestId: params.req.meta?.requestId, + gesture: normalized.gesture, + }); + dispatchedSwipes += 1; + if (pauseMs > 0 && index + 1 < count) await sleep(pauseMs); + } + } catch (error) { + throw discloseDispatchAfterSteps(error, dispatchedSwipes); } if (!result) throw new Error('Swipe orchestration did not execute a gesture.'); return result; diff --git a/src/daemon/scroll-until.ts b/src/daemon/scroll-until.ts index 300ed28fe7..dd956d15ec 100644 --- a/src/daemon/scroll-until.ts +++ b/src/daemon/scroll-until.ts @@ -1,6 +1,6 @@ import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; @@ -71,22 +71,26 @@ export async function runScrollUntilVisible(params: { let result: TResult | undefined; const recentSignatures: string[] = []; - while (true) { - const decision = await decideUntilPass({ - captured: await capture(), - selector, - direction, - platform, - edge, - passes, - passLimit, - recentSignatures, - }); - if (decision.visible) { - return { passes, ...(result === undefined ? {} : { result }) }; + try { + while (true) { + const decision = await decideUntilPass({ + captured: await capture(), + selector, + direction, + platform, + edge, + passes, + passLimit, + recentSignatures, + }); + if (decision.visible) { + return { passes, ...(result === undefined ? {} : { result }) }; + } + result = await scroll(); + passes += 1; } - result = await scroll(); - passes += 1; + } catch (error) { + throw discloseDispatchAfterSteps(error, passes); } } From ac1d5a87e132d35bcbe785a67387dda5b91c10a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 15:56:15 +0200 Subject: [PATCH 13/42] fix(apple-runner): unlisted runner codes are unknown; pre-gesture refusals are no An unlisted or missing runner code already reads `unknown` (the default became `unknown` with the two-valued type), in the reply table and in the status-recovery journal path, which share classifyRunnerReportedError. This adds the one code the runner provably emits before any gesture: INVALID_ARGS -> no. Every emission in the runner sources (apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/): - RunnerTests+Transport.swift:23, :106, :169: request body too large, not UTF-8, or undecodable; nothing is dispatched. - RunnerTests+CommandDispatch.swift:33, :59, :78: status, appState, pasteboardWrite argument guards, before the read or the pasteboard write. - RunnerTests+CommandExecution.swift:256, :296, :316, :346 (scroll and desktopScroll direction and duration guards), :290 and :336 (no gesture or wheel plan), :593 and :599 (missing or invalid gesturePlan): each returns before executeScrollDragGesture, desktopScrollAt, or plannedGestureExecution. - RunnerTests+ScrollDragExecution.swift:86 (non-finite drag coordinates) and :159 (no synthesized coordinate frame), before synthesis or dragAt. - RunnerTests+SequenceExecution.swift:25, :28, :130, :135 (via :219): sequence validation, before assembleSequenceExecution at :46. Not listed, with the evidence: - UNSUPPORTED_OPERATION stays `unknown`. It is also the reply after a gesture ran: RunnerSynthesizedGesture.m:350-353 returns "private XCTest event synthesis failed" after synthesizeWithError: ran, and RunnerTests+TvRemote.swift:119-126 returns "element tap failed" after element.tap() raised. The row ios-runner.reply.UNSUPPORTED_OPERATION pins it. - "Active app interaction viewport is unavailable" (RunnerTests+CommandExecution.swift:628) is a pre-gesture refusal, but its code is COMMAND_FAILED; mapping it would key on message text. It stays `unknown` until the runner gives it its own code. Mutations: removing the INVALID_ARGS entry fails ios-runner.reply.INVALID_ARGS (driven through the real reply path against the fake runner); adding UNSUPPORTED_OPERATION as `no` fails ios-runner.reply.UNSUPPORTED_OPERATION. --- contracts/fixtures/dispatch-disclosure.json | 12 ++++++++++++ .../__tests__/runner-dispatch-disclosure.test.ts | 2 ++ .../platform-apple/src/runner/runner-contract.ts | 6 ++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index dcc0a9ba93..79bb064ea5 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -61,6 +61,12 @@ "trigger": "assertAndroidPressStayedInApp observed an escape surface in the foreground after the press; the guard stamps nothing and the daemon seam fills unknown", "dispatched": "unknown" }, + { + "id": "ios-runner.reply.INVALID_ARGS", + "producer": "ios-runner", + "trigger": "structured runner reply with code INVALID_ARGS: request decoding or argument validation refused before any gesture", + "dispatched": "no" + }, { "id": "ios-runner.reply.RUNNER_BUSY", "producer": "ios-runner", @@ -133,6 +139,12 @@ "trigger": "structured runner reply with code MAIN_THREAD_TIMEOUT: abandoned work may still land", "dispatched": "unknown" }, + { + "id": "ios-runner.reply.UNSUPPORTED_OPERATION", + "producer": "ios-runner", + "trigger": "structured runner reply with code UNSUPPORTED_OPERATION, which a synthesized gesture or an element tap also reports after it ran", + "dispatched": "unknown" + }, { "id": "ios-runner.reply.unlisted-code", "producer": "ios-runner", diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 7061471abc..7281459cbb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -128,6 +128,8 @@ function statusReply(data: Record): FakeRunnerResponse[] { } const DRIVERS: Record Promise> = { + 'ios-runner.reply.INVALID_ARGS': () => replyFailure('INVALID_ARGS'), + 'ios-runner.reply.UNSUPPORTED_OPERATION': () => replyFailure('UNSUPPORTED_OPERATION'), 'ios-runner.reply.RUNNER_BUSY': () => replyFailure('RUNNER_BUSY'), 'ios-runner.reply.RUNNER_WEDGED': () => replyFailure('RUNNER_WEDGED'), 'ios-runner.reply.APP_NOT_RUNNING': () => replyFailure('APP_NOT_RUNNING'), diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 6bea8c1cec..e4a2d6ff4b 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -277,10 +277,12 @@ const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlyMap< /** * Runner codes whose reply proves the command never reached the device. The refusals answer before * the command runs, the selector refusals included: the runner resolves the element and refuses - * before any gesture. Every other code, and a reply without one, is `unknown`: a failure after the - * command started cannot prove whether its gesture landed. + * before any gesture. `INVALID_ARGS` comes only from request decoding and argument validation, + * each ahead of any gesture. Every other code, and a reply without one, is `unknown`: + * `UNSUPPORTED_OPERATION` is also what a synthesized gesture or element tap reports after it ran. */ const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new Map([ + ['INVALID_ARGS', 'no'], ['ELEMENT_NOT_FOUND', 'no'], ['ELEMENT_OFFSCREEN', 'no'], ['AMBIGUOUS_MATCH', 'no'], From 086f6a86ec2ccddaf92e9fd6e9c62446e6dc2568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 15:59:41 +0200 Subject: [PATCH 14/42] fix(daemon): every failed interaction carries dispatched, plain errors included The interaction seam and the runner lifecycle both rethrew a failure that was not an AppError untouched, so a plain Error from a backend reached the wire without `details.dispatched`. Both now normalize at the boundary with asAppError (fallback code UNKNOWN, the same code the request router's normalizeError gives a plain Error, so the wire code does not change) before disclosing: the seam fills `unknown` (or `no` for a read-only command), and the runner lifecycle keeps its `no` for a failure before the exchange started. Tests: a plain Error thrown by the bound runtime reaches the caller of handleInteractionCommands as an AppError whose normalized details say `unknown`; a plain Error from ensureRunnerSession reaches the caller of runAppleRunnerCommand with `no` and no command sent. Mutations: restoring the raw rethrow for plain Errors in the seam fails the first; restoring `if (!(error instanceof AppError)) throw error` in executeRunnerCommand fails the second. --- ...nner-lifecycle-dispatch-disclosure.test.ts | 11 +++++++ .../src/runner/runner-lifecycle.ts | 8 ++--- .../interaction-dispatch-disclosure.test.ts | 29 ++++++++++++++++++- .../interaction-dispatch-disclosure.ts | 8 +++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index b965cf80eb..900bf4dce6 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -160,3 +160,14 @@ for (const row of ROWS) { }); }); } + +test('a plain Error before the exchange is normalized and discloses no', async () => { + mockEnsureRunnerSession.mockRejectedValueOnce(new Error('spawn EACCES')); + await assert.rejects(tap(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'spawn EACCES'); + assert.equal(error.details?.dispatched, 'no'); + return true; + }); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 0); +}); diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 33cec5c952..bbc7774c23 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -276,10 +276,10 @@ export async function executeRunnerCommand( try { return await executeRunnerCommandAttempt(device, command, options, exchange); } catch (error) { - if (!(error instanceof AppError)) throw error; - if (!exchange.entered) throw discloseDispatch(error, 'no'); - if (isRunnerPreSendRefusal(error)) throw discloseUnclassifiedDispatch(error, 'no'); - throw error; + const failure = asAppError(error); + if (!exchange.entered) throw discloseDispatch(failure, 'no'); + if (isRunnerPreSendRefusal(failure)) throw discloseUnclassifiedDispatch(failure, 'no'); + throw failure; } } diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 55adcfebd2..af306ae2f1 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { beforeEach, test, vi } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; -import { AppError, type DispatchDisclosure } from '@agent-device/kernel/errors'; +import { AppError, type DispatchDisclosure, normalizeError } from '@agent-device/kernel/errors'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import { assertDispatchDisclosureDriversMatchRows, @@ -237,3 +237,30 @@ test('a read-only command discloses no over a producer verdict it throws', async }, ); }); + +test('a plain Error a backend throws reaches the wire with dispatched unknown', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('dispatch-disclosure-plain-error'); + sessionStore.set(session.name, session); + const bindings = getRuntimeBindings(); + const bindDevice = vi.fn(async () => { + throw new Error('socket hang up'); + }) as unknown as typeof bindings.bindDevice; + await assert.rejects( + handleInteractionCommands({ + req: { token: 't', session: session.name, command: 'press', positionals: ['@e1'], flags: {} }, + sessionName: session.name, + sessionStore, + contextFromFlags, + ...bindings, + bindDevice, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'socket hang up'); + assert.equal(normalizeError(error).details?.dispatched, 'unknown'); + return true; + }, + ); + assert.equal(vi.mocked(bindDevice).mock.calls.length, 1); +}); diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts index 002c13552d..7f8558e5d5 100644 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -1,5 +1,5 @@ import { - AppError, + asAppError, discloseDispatch, discloseUnclassifiedDispatch, } from '@agent-device/kernel/errors'; @@ -27,8 +27,10 @@ export async function discloseInteractionDispatch( error: { ...response.error, details: { ...response.error.details, dispatched } }, }; } catch (error) { - if (!(error instanceof AppError)) throw error; - throw readOnly ? discloseDispatch(error, 'no') : discloseUnclassifiedDispatch(error, 'unknown'); + const failure = asAppError(error); + throw readOnly + ? discloseDispatch(failure, 'no') + : discloseUnclassifiedDispatch(failure, 'unknown'); } } From 9834977540ed0bccd9f43e0636f47663533aabd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 16:10:13 +0200 Subject: [PATCH 15/42] fix(errors): close the smaller dispatch-disclosure gaps from review - adb text: only a non-empty chunk is a dispatched step, so `type "\nx"` with adb missing fails its leading ENTER with `no` instead of `unknown`. Row android-adb.input-text.tool-missing-before-first-input. - The daemon.unclassified.session-replaced row is dropped: its driver replaced the session inside the touch, after the runtime had read it, and the seam reads only the failure (interaction-dispatch-disclosure.ts has no reference to the session store), so no placement of the replacement can change the verdict. daemon.unclassified covers the case. - waitForRunner: the early-exit error after the loop gets the same write evidence as the connect error, so a runner that exited before any attempt could write says `no`. - fill: admitFill wraps the whole preamble (surface policy, target parse, bind, recorded-parameter and settle-flag checks, @ref preamble); a refusal it returns or throws is `no`. There is no single admission seam across interaction commands (press has admitTargetedTouch, fill had none), so the fill preamble is wrapped as a whole, and prepareTouchDispatch, the bind every touch route shares, marks its own refusal. Row daemon.refusal.fill-admission. - Driver ownership compares slash-separated paths on every platform. - fill: the second-pass input failure is its own row, android-adb.fill.second-pass-input-failed (unknown, dispatchedSteps 2), driven through fillAndroid; android-adb.fill.unverified keeps the verification-exhausted case. - Table checks: fallsBack exactly on maestro-direct rows, implementedBy only on webdriver rows, values only no or unknown, and every driver file carries the per-row loop DISPATCH_DISCLOSURE_ROW_LOOP. - Tests: the overwrite test overwrites a seam-filled value; the direct iOS fallback pins that each legacy message falls back with `no` and not without it; the direct-iOS suite resets its capture mock per test; the refused-fallback transport test asserts the fallback ran once. Mutations: counting an empty chunk as a step fails tool-missing-before-first-input; dropping the focus-tap count in fillAndroid fails second-pass-input-failed; returning admitFill's refusal unwrapped fails daemon.refusal.fill-admission; throwing the bare early-exit error fails the new waitForRunner test; a fallsBack or implementedBy on a daemon row fails the vocabulary test; skipping a driver's per-row loop fails the driver-file test; a message sniff returning false for legacy texts fails the direct iOS fallback test. The path-separator fix has no POSIX mutation. --- contracts/fixtures/dispatch-disclosure.json | 24 +++- .../src/dispatch-disclosure.fixtures.ts | 12 +- .../contracts/src/dispatch-disclosure.test.ts | 18 +-- .../src/__tests__/dispatch-disclosure.test.ts | 80 +++++++++++- packages/platform-android/src/text-input.ts | 7 +- .../runner-startup-transport.test.ts | 20 +++ .../src/runner/runner-startup-transport.ts | 5 +- .../__tests__/direct-ios-selector.test.ts | 19 ++- .../interaction-dispatch-disclosure.test.ts | 35 ++---- .../interaction-touch-direct-ios.test.ts | 5 +- .../interaction-dispatch-disclosure.ts | 12 +- .../internal/interaction-touch-fill.ts | 117 ++++++++++++------ .../internal/interaction-touch-prepare.ts | 3 +- 13 files changed, 263 insertions(+), 94 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 79bb064ea5..15b99ef6db 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -12,15 +12,15 @@ "dispatched": "no" }, { - "id": "daemon.unclassified", + "id": "daemon.refusal.fill-admission", "producer": "daemon", - "trigger": "an interaction failure no producer classified; the fallback is unknown whatever the session runtime revision did", - "dispatched": "unknown" + "trigger": "fill admission (admitFill) refused the request before any backend call, e.g. a target with no text", + "dispatched": "no" }, { - "id": "daemon.unclassified.session-replaced", + "id": "daemon.unclassified", "producer": "daemon", - "trigger": "an unclassified failure after the session object was replaced mid-request", + "trigger": "an interaction failure no producer classified; the seam reads only the failure, never session state", "dispatched": "unknown" }, { @@ -247,10 +247,22 @@ "trigger": "adb input text failed after at least one chunk was typed (details.dispatchedSteps)", "dispatched": "unknown" }, + { + "id": "android-adb.input-text.tool-missing-before-first-input", + "producer": "android-adb", + "trigger": "type \"\\nfiled\": adb is missing, so the leading ENTER keyevent, the first input sent, fails with TOOL_MISSING; the empty text before the newline sends nothing and is not a dispatched step", + "dispatched": "no" + }, { "id": "android-adb.fill.unverified", "producer": "android-adb", - "trigger": "fill sent the text and its verification still failed after the last clear-and-retype pass. A throw from inside the second pass keeps that pass's adb input verdict (unknown with dispatchedSteps), though the first pass already typed", + "trigger": "fill sent the text and its verification still failed after the last clear-and-retype pass", + "dispatched": "unknown" + }, + { + "id": "android-adb.fill.second-pass-input-failed", + "producer": "android-adb", + "trigger": "fill's first clear-and-retype pass typed and failed verification; the second pass's adb input text then failed, so the fill reports unknown with details.dispatchedSteps counting the steps both passes sent", "dispatched": "unknown" }, { diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index 6e4b1620df..f6d828931a 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -35,7 +35,8 @@ export const DISPATCH_DISCLOSURE_TABLE_PATH = path.join( /** * The test file that drives each row through its real producer, keyed by row-id prefix; the * longest matching prefix owns the row. A row naming `implementedBy` has no owner until that branch - * lands. + * lands. Each driver file runs one test per owned row with the loop `for (const row of ) {` + * followed by `test(\`${row.id}`, which {@link DISPATCH_DISCLOSURE_ROW_LOOP} matches. */ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> = { 'daemon.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', @@ -55,6 +56,10 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts', }; +/** The per-row driving loop every driver file carries. */ +export const DISPATCH_DISCLOSURE_ROW_LOOP = + /for \(const row of [A-Z_]+\) \{\s*test\(`\$\{row\.id\}/; + export function dispatchDisclosureDriverOwner(rowId: string): string | undefined { const prefix = Object.keys(DISPATCH_DISCLOSURE_DRIVER_OWNERS) .filter((candidate) => rowId.startsWith(candidate)) @@ -72,7 +77,10 @@ export function dispatchDisclosureRowsOwnedBy( driverFileUrl: string, tableText: string, ): DispatchDisclosureRow[] { - const driverFile = path.relative(REPO_ROOT, fileURLToPath(driverFileUrl)); + const driverFile = path + .relative(REPO_ROOT, fileURLToPath(driverFileUrl)) + .split(path.sep) + .join('/'); return parseDispatchDisclosureTable(tableText).filter( (row) => row.implementedBy === undefined && dispatchDisclosureDriverOwner(row.id) === driverFile, diff --git a/packages/contracts/src/dispatch-disclosure.test.ts b/packages/contracts/src/dispatch-disclosure.test.ts index b0d7cd256b..22f768c380 100644 --- a/packages/contracts/src/dispatch-disclosure.test.ts +++ b/packages/contracts/src/dispatch-disclosure.test.ts @@ -11,6 +11,7 @@ import { import { DISPATCH_DISCLOSURE_DRIVER_OWNERS, DISPATCH_DISCLOSURE_PRODUCERS, + DISPATCH_DISCLOSURE_ROW_LOOP, dispatchDisclosureDriverOwner, DISPATCH_DISCLOSURE_TABLE_PATH, parseDispatchDisclosureTable, @@ -21,12 +22,12 @@ const DISPATCH_VALUES: readonly string[] = ['no', 'unknown']; test('a producer verdict overwrites; the seam verdict fills only an unclassified failure', () => { const error = new AppError('COMMAND_FAILED', 'tap failed', { hint: 'retry' }); - assert.equal(discloseDispatch(error, 'unknown', { dispatchedSteps: 2 }), error); - assert.deepEqual(error.details, { hint: 'retry', dispatchedSteps: 2, dispatched: 'unknown' }); - discloseUnclassifiedDispatch(error, 'no'); + assert.equal(discloseUnclassifiedDispatch(error, 'unknown'), error); assert.equal(error.details?.dispatched, 'unknown'); - const unclassified = new AppError('COMMAND_FAILED', 'tap failed'); - assert.equal(discloseUnclassifiedDispatch(unclassified, 'no').details?.dispatched, 'no'); + assert.equal(discloseDispatch(error, 'no', { dispatchedSteps: 2 }), error); + assert.deepEqual(error.details, { hint: 'retry', dispatchedSteps: 2, dispatched: 'no' }); + discloseUnclassifiedDispatch(error, 'unknown'); + assert.equal(error.details?.dispatched, 'no'); }); test('dispatch-disclosure rows are unique and use the declared vocabulary', () => { @@ -39,6 +40,8 @@ test('dispatch-disclosure rows are unique and use the declared vocabulary', () = assert.ok((DISPATCH_DISCLOSURE_PRODUCERS as readonly string[]).includes(row.producer), row.id); assert.ok(DISPATCH_VALUES.includes(row.dispatched), row.id); assert.ok(row.trigger.trim().length > 0, row.id); + assert.equal(row.fallsBack !== undefined, row.id.startsWith('maestro-direct.'), row.id); + if (row.implementedBy !== undefined) assert.ok(row.id.startsWith('webdriver.'), row.id); } }); @@ -61,8 +64,9 @@ test('every row without implementedBy has a driver file, and every owner prefix const source = fs.readFileSync(driverPath, 'utf8'); assert.ok( /dispatchDisclosureRowsOwnedBy\(\s*import\.meta\.url,/.test(source) && - source.includes('assertDispatchDisclosureDriversMatchRows('), - `${driver} must drive the rows it owns and assert its drivers match them`, + source.includes('assertDispatchDisclosureDriversMatchRows(') && + DISPATCH_DISCLOSURE_ROW_LOOP.test(source), + `${driver} must drive the rows it owns, one test per row, and assert its drivers match them`, ); } }); diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index dd15896a4e..089f16f60a 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -8,14 +8,22 @@ import { DISPATCH_DISCLOSURE_TABLE_PATH, dispatchDisclosureRowsOwnedBy, } from '@agent-device/contracts/dispatch-disclosure-fixtures'; -import { withAndroidAdbProvider, type AndroidAdbExecutor } from '../adb-executor.ts'; +import { + androidAdbResultError, + withAndroidAdbProvider, + type AndroidAdbExecutor, +} from '../adb-executor.ts'; import { completeAndroidFillVerification } from '../fill-verification.ts'; import { pressAndroid } from '../input-actions.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; -import { typeAndroid } from '../text-input.ts'; +import { fillAndroid, typeAndroid } from '../text-input.ts'; import { executeAndroidTouchHelperPlan } from '../touch-helper.ts'; import { lowerAndroidTouchPlan } from '../touch-plan-lowering.ts'; -import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; +import { + ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + createAndroidSnapshotHelperExecutor, +} from './test-utils/android-snapshot-helper.ts'; +import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; import { withFakeAdb } from './test-utils/fake-adb.ts'; import { ANDROID_TOUCH_HELPER_MANIFEST as manifest, @@ -49,7 +57,7 @@ afterEach(async () => { await resetAndroidSnapshotHelperSessions(); }); -function isShellInput(args: readonly string[], subcommand: 'tap' | 'text'): boolean { +function isShellInput(args: readonly string[], subcommand: 'tap' | 'text' | 'keyevent'): boolean { return args[0] === 'shell' && args[1] === 'input' && args[2] === subcommand; } @@ -72,6 +80,63 @@ async function typeFailingOnSecondChunk(): Promise { ); } +async function typeLeadingNewlineWithoutAdb(): Promise { + await withFakeAdb( + (args) => + isShellInput(args, 'text') || isShellInput(args, 'keyevent') + ? new AppError('TOOL_MISSING', 'adb not found in PATH') + : undefined, + async ({ device }) => await typeAndroid(device, '\nfiled'), + ); +} + +const FILL_MISMATCH_XML = + ''; + +/** + * The first clear-and-retype pass types and reads back other text; the second pass's `input text` + * then fails. Each capture advances the clock past the verification deadline, so the first pass + * gives up after one sample instead of waiting the real deadline out. + */ +async function fillFailingInSecondPass(): Promise { + let offsetMs = 0; + const realNow = Date.now.bind(Date); + const now = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + offsetMs); + let textInputs = 0; + const exec: AndroidAdbExecutor = async (args) => { + const result = { exitCode: 0, stdout: '', stderr: '' }; + if (!isShellInput(args, 'text')) return result; + textInputs += 1; + if (textInputs < 2) return result; + throw androidAdbResultError(`adb ${args.join(' ')} exited with code 1`, { + exitCode: 1, + stdout: '', + stderr: 'error: device offline', + }); + }; + try { + await withAndroidAdbProvider( + { + exec: createAndroidSnapshotHelperExecutor({ + exec, + captureXml: () => { + offsetMs += 2_000; + return FILL_MISMATCH_XML; + }, + }), + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + await fillAndroid(ANDROID_EMULATOR, 10, 10, 'filed'); + }, + ); + } finally { + now.mockRestore(); + assert.equal(textInputs, 2); + } +} + async function oneShotGesture(instrument: AndroidAdbExecutor): Promise { const device = makeIsolatedDevice(); await withAndroidAdbProvider( @@ -99,6 +164,13 @@ const DRIVERS: Record Promise; dispatchedSteps?: drive: typeFailingOnSecondChunk, dispatchedSteps: 1, }, + 'android-adb.input-text.tool-missing-before-first-input': { + drive: typeLeadingNewlineWithoutAdb, + }, + 'android-adb.fill.second-pass-input-failed': { + drive: fillFailingInSecondPass, + dispatchedSteps: 2, + }, 'android-adb.fill.unverified': { drive: async () => completeAndroidFillVerification('filed the expense', null, { diff --git a/packages/platform-android/src/text-input.ts b/packages/platform-android/src/text-input.ts index 80de2dc0ff..cf9c72a4df 100644 --- a/packages/platform-android/src/text-input.ts +++ b/packages/platform-android/src/text-input.ts @@ -300,8 +300,10 @@ async function typeAndroidShell( for (const [partIndex, part] of parts.entries()) { const chunks = chunkAndroidInputText(part, options.chunkSize); for (const [chunkIndex, chunk] of chunks.entries()) { - await typeAndroidShellChunk(device, chunk); - dispatchedSteps += 1; + if (chunk) { + await typeAndroidShellChunk(device, chunk); + dispatchedSteps += 1; + } if ( options.delayMs > 0 && (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length) @@ -321,7 +323,6 @@ async function typeAndroidShell( } async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise { - if (!text) return; try { await runAndroidShell(device, ['input', 'text', encodeAndroidInputText(text)]); } catch (error) { diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts index 7bc6ccdeae..8e94fa5842 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts @@ -158,6 +158,25 @@ test('waitForRunner types a failed simulator fallback as a refused connection', assert.equal(mockRunCmd.mock.calls.length, 1); }); +test('waitForRunner discloses no when the runner exited before any attempt could write', async () => { + const session: RunnerSession = { + ...makeReadyRunnerSession(), + device: iosDevice, + deviceId: iosDevice.id, + child: { pid: 1234, exitCode: 65 } as ExecBackgroundResult['child'], + }; + await assert.rejects( + () => waitForRunner(iosDevice, 8100, { command: 'tap', x: 1, y: 1 }, undefined, 100, session), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Runner did not accept connection (xcodebuild exited early)'); + assert.equal(error.details?.dispatched, 'no'); + return true; + }, + ); + assert.equal(mockUsbmuxPostCommand.mock.calls.length, 0); +}); + test('waitForRunner discloses unknown when an attempt may have written before a refused fallback', async () => { vi.stubGlobal( 'fetch', @@ -173,6 +192,7 @@ test('waitForRunner discloses unknown when an attempt may have written before a return true; }, ); + assert.equal(mockRunCmd.mock.calls.length, 1); }); test('waitForRunner wakes a simulator startup retry when the listener reports ready', async () => { diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index 62be0d2b7c..e22051f1fc 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -160,7 +160,10 @@ export async function waitForRunner( } if (session?.child.exitCode !== null && session?.child.exitCode !== undefined) { - throw await buildRunnerEarlyExitError({ session, port, logPath }); + throw withRunnerWriteEvidence( + await buildRunnerEarlyExitError({ session, port, logPath }), + commandMayHaveBeenWritten, + ); } throw withRunnerWriteEvidence( buildRunnerConnectError({ diff --git a/src/daemon/__tests__/direct-ios-selector.test.ts b/src/daemon/__tests__/direct-ios-selector.test.ts index d0795e7743..73ccb93375 100644 --- a/src/daemon/__tests__/direct-ios-selector.test.ts +++ b/src/daemon/__tests__/direct-ios-selector.test.ts @@ -22,6 +22,14 @@ function makeSession( }; } +/** The message texts the fallback once sniffed; the disclosure alone must decide now. */ +const LEGACY_FALLBACK_MESSAGES = [ + 'fetch failed', + 'Runner command deadline exceeded: timed out', + 'Runner did not accept connection', + 'Invalid runner response', +]; + function refusal(code: AppError['code'], message: string): AppError { return new AppError(code, message, { dispatched: 'no' }); } @@ -55,17 +63,16 @@ test('a pre-send COMMAND_FAILED falls back; the message text never decides', () ), true, ); - for (const message of [ - 'fetch failed', - 'Runner command deadline exceeded: timed out', - 'Runner did not accept connection', - 'Invalid runner response', - ]) { + for (const message of LEGACY_FALLBACK_MESSAGES) { assert.equal( isDirectIosSelectorFallbackError(new AppError('COMMAND_FAILED', message), options), false, message, ); + assert.equal( + isDirectIosSelectorFallbackError(refusal('COMMAND_FAILED', message), options), + true, + ); } }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index af306ae2f1..9b2d0147e7 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -13,6 +13,7 @@ import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory import { makeAndroidSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { getRuntimeBindings, + mockFillPoint, mockTapPoint, resetGetRuntimeFixture, } from '../../../__tests__/interaction-get-runtime-fixture.ts'; @@ -36,17 +37,11 @@ beforeEach(() => { }); type PressScenario = { - command?: 'press' | 'get'; + command?: 'press' | 'get' | 'fill'; positionals: string[]; - /** Runs inside the device touch, before it fails. */ - duringTouch?: (store: ReturnType, sessionName: string) => void; }; -async function press({ - command = 'press', - positionals, - duringTouch, -}: PressScenario): Promise { +async function press({ command = 'press', positionals }: PressScenario): Promise { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure'); session.snapshot = { @@ -64,12 +59,6 @@ async function press({ backend: 'xctest', }; sessionStore.set(session.name, session); - if (duringTouch) { - mockTapPoint.mockImplementationOnce(async () => { - duringTouch(sessionStore, session.name); - throw new AppError('COMMAND_FAILED', 'touch failed'); - }); - } const response = await handleInteractionCommands({ req: { token: 't', session: session.name, command, positionals, flags: {} }, sessionName: session.name, @@ -89,6 +78,14 @@ async function refusedPress(positionals: string[]): Promise { } } +async function refusedFill(positionals: string[]): Promise { + try { + return await press({ command: 'fill', positionals }); + } finally { + assert.equal(mockFillPoint.mock.calls.length, 0, 'a refusal must not reach the device'); + } +} + async function pressAfterUnclassifiedTouchFailure( details?: Record, ): Promise { @@ -154,16 +151,8 @@ async function swipeRefusedOnSecondRepetition(): Promise { const DRIVERS: Record Promise> = { 'daemon.refusal.ref-not-found': () => refusedPress(['@e9']), 'daemon.refusal.admission': () => refusedPress([]), + 'daemon.refusal.fill-admission': () => refusedFill(['@e1']), 'daemon.unclassified': () => pressAfterUnclassifiedTouchFailure(), - 'daemon.unclassified.session-replaced': () => - press({ - positionals: ['@e1'], - duringTouch: (store, name) => { - const current = store.get(name); - assert.ok(current); - store.set(name, { ...current }); - }, - }), 'daemon.series.swipe-later-repetition-refused': swipeRefusedOnSecondRepetition, 'daemon.read-only-command': () => press({ command: 'get', positionals: ['text', 'label="Missing"'] }), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts index 36e9f8d9d0..c1948270a4 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts @@ -39,7 +39,10 @@ vi.mock('../../../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -beforeEach(() => resetGetRuntimeFixture()); +beforeEach(() => { + resetGetRuntimeFixture(); + vi.mocked(captureSnapshotWithInteractor).mockReset(); +}); test.each([ ['ELEMENT_NOT_FOUND', 'element not found'], diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts index 7f8558e5d5..35ae57189b 100644 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -1,4 +1,5 @@ import { + type AppError, asAppError, discloseDispatch, discloseUnclassifiedDispatch, @@ -35,10 +36,17 @@ export async function discloseInteractionDispatch( } /** A failure response built before any dispatch: the requested operation never reached the device. */ -export function refusedBeforeDispatch(response: DaemonResponse): DaemonResponse { +export function refusedBeforeDispatch( + response: Response, +): Response { if (response.ok) return response; return { - ok: false, + ...response, error: { ...response.error, details: { ...response.error.details, dispatched: 'no' } }, }; } + +/** A failure thrown before any dispatch: the requested operation never reached the device. */ +export function thrownBeforeDispatch(error: unknown): AppError { + return discloseDispatch(asAppError(error), 'no'); +} diff --git a/src/daemon/interaction/internal/interaction-touch-fill.ts b/src/daemon/interaction/internal/interaction-touch-fill.ts index 6cd04f3f7a..6e00f1f941 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -25,6 +25,8 @@ import { import { dispatchRuntimeInteraction } from './interaction-touch-runtime.ts'; import { parseFillTarget } from './interaction-touch-targets.ts'; import { prepareTouchDispatch } from './interaction-touch-prepare.ts'; +import { refusedBeforeDispatch, thrownBeforeDispatch } from './interaction-dispatch-disclosure.ts'; +import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import { noActiveSessionError } from '@agent-device/kernel/contracts'; /** @@ -33,45 +35,23 @@ import { noActiveSessionError } from '@agent-device/kernel/contracts'; * shares in shape with press, and its response payloads. */ -export async function dispatchFillViaRuntime( - params: InteractionRouteInput & { - captureSnapshotForSession: CaptureSnapshotForSession; - refSnapshotFlagGuardResponse: RefSnapshotFlagGuardResponse; - }, -): Promise { - const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); - if (session) { - const unsupportedSurfaceResponse = unsupportedMacOsDesktopSurfaceInteraction(session, 'fill'); - if (unsupportedSurfaceResponse) return unsupportedSurfaceResponse; - } - if (!session) return noActiveSessionError(); - const parsedTarget = parseFillTarget(req.positionals ?? []); - if (!parsedTarget.ok) return parsedTarget.response; - const prepared = await prepareTouchDispatch( - params, - session, - 'fill', - parsedTarget.target.kind !== 'point', - ); - if (!prepared.ok) return prepared.response; - const { touchExecutor } = prepared; - assertRecordedFillParameterization({ - flags: req.flags, - replayPlanStep: req.internal?.replayPlanStep === true, - isSessionRecording: isSessionRecording(session), - }); - const invalidSettleFlags = settleFlagGuardResponse('fill', req.flags); - if (invalidSettleFlags) return invalidSettleFlags; +type FillParams = InteractionRouteInput & { + captureSnapshotForSession: CaptureSnapshotForSession; + refSnapshotFlagGuardResponse: RefSnapshotFlagGuardResponse; +}; - const refPreamble = await prepareFillRefTarget( - params, - session, - parsedTarget.target, - parsedTarget.refGeneration, - ); - if (refPreamble.response) return refPreamble.response; - const { staleRefsWarning } = refPreamble; +type AdmittedFill = { + session: SessionState; + parsedTarget: Extract, { ok: true }>; + touchExecutor: BoundTouchExecutor; + staleRefsWarning: string | undefined; +}; + +export async function dispatchFillViaRuntime(params: FillParams): Promise { + const admission = await admitFill(params); + if ('response' in admission) return admission.response; + const { session, parsedTarget, touchExecutor, staleRefsWarning } = admission.admitted; + const { req, sessionName } = params; const replayTargetGuard = req.internal?.replayTargetGuard; return await dispatchRuntimeInteraction(params, { @@ -106,6 +86,67 @@ export async function dispatchFillViaRuntime( }); } +/** + * Everything `fill` checks before it touches the device: surface policy, target parsing, the one + * bind, recorded-parameter and settle-flag validation, and the `@ref` preamble. A refusal from any + * of them, returned or thrown, is `dispatched: no`. + */ +async function admitFill( + params: FillParams, +): Promise<{ response: DaemonResponse } | { admitted: AdmittedFill }> { + try { + const admission = await readFillAdmission(params); + return 'response' in admission + ? { response: refusedBeforeDispatch(admission.response) } + : admission; + } catch (error) { + throw thrownBeforeDispatch(error); + } +} + +async function readFillAdmission( + params: FillParams, +): Promise<{ response: DaemonResponse } | { admitted: AdmittedFill }> { + const { req, sessionName, sessionStore } = params; + const session = sessionStore.get(sessionName); + if (session) { + const unsupportedSurfaceResponse = unsupportedMacOsDesktopSurfaceInteraction(session, 'fill'); + if (unsupportedSurfaceResponse) return { response: unsupportedSurfaceResponse }; + } + if (!session) return { response: noActiveSessionError() }; + const parsedTarget = parseFillTarget(req.positionals ?? []); + if (!parsedTarget.ok) return { response: parsedTarget.response }; + const prepared = await prepareTouchDispatch( + params, + session, + 'fill', + parsedTarget.target.kind !== 'point', + ); + if (!prepared.ok) return { response: prepared.response }; + assertRecordedFillParameterization({ + flags: req.flags, + replayPlanStep: req.internal?.replayPlanStep === true, + isSessionRecording: isSessionRecording(session), + }); + const invalidSettleFlags = settleFlagGuardResponse('fill', req.flags); + if (invalidSettleFlags) return { response: invalidSettleFlags }; + const refPreamble = await prepareFillRefTarget( + params, + session, + parsedTarget.target, + parsedTarget.refGeneration, + ); + if (refPreamble.response) return { response: refPreamble.response }; + return { + admitted: { + session, + parsedTarget, + touchExecutor: prepared.touchExecutor, + staleRefsWarning: refPreamble.staleRefsWarning, + }, + }; +} + // The fill @ref preamble shared with the press path's shape: read staleness // relative to what the client knew BEFORE any internal recapture, validate // @ref-incompatible flags, enforce iOS mutation freshness, and run the Android diff --git a/src/daemon/interaction/internal/interaction-touch-prepare.ts b/src/daemon/interaction/internal/interaction-touch-prepare.ts index 8a16ea787d..55edff9e99 100644 --- a/src/daemon/interaction/internal/interaction-touch-prepare.ts +++ b/src/daemon/interaction/internal/interaction-touch-prepare.ts @@ -7,6 +7,7 @@ import { } from '../../touch-runtime.ts'; import type { InteractionRouteInput } from './types.ts'; import type { DaemonFailureResponse } from '@agent-device/kernel/contracts'; +import { refusedBeforeDispatch } from './interaction-dispatch-disclosure.ts'; export type PreparedTouchDispatch = | Readonly<{ ok: false; response: DaemonFailureResponse }> @@ -26,7 +27,7 @@ export async function prepareTouchDispatch( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); - if (!bound.ok) return bound; + if (!bound.ok) return { ok: false, response: refusedBeforeDispatch(bound.response) }; return { ok: true, touchExecutor: createBoundTouchExecutor( From 7e6fafbd81e165ab4ce020a6dfe5d40a2325e768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 16:16:13 +0200 Subject: [PATCH 16/42] refactor(interaction): split the series and fill admission below the complexity gate fallow flagged typeAndroidImeHelper, executeGenericPress, and readFillAdmission after the series and admission changes. The two Android text loops, which had the same shape, now share one plan (planAndroidTextSteps) and one sender (sendAndroidTextSteps) that owns the dispatched-step count; the ENTER between lines goes through pressAndroidEnterKey, so a missing adb before any input is `no` on the IME path too. executeGenericPress delegates one press to pressOnce, and fill admission reads its session and target in readFillTarget. Behavior is unchanged otherwise: pauses fall after every chunk except the last of the text, and an empty line still sends nothing. The existing Android dispatch-disclosure, text-input, and fill suites pass unchanged; counting an empty chunk as a step in sendAndroidTextSteps still fails android-adb.input-text.tool-missing-before-first-input. --- packages/contracts/src/touch-runtime.ts | 18 ++- packages/platform-android/src/text-input.ts | 108 +++++++++++------- .../internal/interaction-touch-fill.ts | 25 ++-- 3 files changed, 97 insertions(+), 54 deletions(-) diff --git a/packages/contracts/src/touch-runtime.ts b/packages/contracts/src/touch-runtime.ts index 30ffd6e5a0..a7215f7ea0 100644 --- a/packages/contracts/src/touch-runtime.ts +++ b/packages/contracts/src/touch-runtime.ts @@ -268,11 +268,7 @@ async function executeGenericPress( try { for (let index = 0; index < options.count; index += 1) { const [dx, dy] = pressJitter(index, options.jitterPx); - const result = doubleTap - ? await doubleTap.call(interactor, point.x + dx, point.y + dy) - : options.holdMs > 0 - ? await interactor.longPress(point.x + dx, point.y + dy, options.holdMs) - : await interactor.tap(point.x + dx, point.y + dy); + const result = await pressOnce(interactor, doubleTap, point.x + dx, point.y + dy, options); dispatchedPresses += 1; first ??= result; if (index < options.count - 1 && options.intervalMs > 0) { @@ -285,6 +281,18 @@ async function executeGenericPress( return first; } +async function pressOnce( + interactor: Interactor, + doubleTap: NonNullable | undefined, + x: number, + y: number, + options: PressPointOptions, +): Promise | void> { + if (doubleTap) return await doubleTap.call(interactor, x, y); + if (options.holdMs > 0) return await interactor.longPress(x, y, options.holdMs); + return await interactor.tap(x, y); +} + const PRESS_JITTER = [ [0, 0], [1, 0], diff --git a/packages/platform-android/src/text-input.ts b/packages/platform-android/src/text-input.ts index cf9c72a4df..df47670102 100644 --- a/packages/platform-android/src/text-input.ts +++ b/packages/platform-android/src/text-input.ts @@ -227,28 +227,14 @@ async function typeAndroidImeHelper( delayMs: number, ): Promise { const adb = resolveAndroidAdbExecutor(device); - const parts = text.split('\n'); - let dispatchedSteps = 0; - try { - for (const [partIndex, part] of parts.entries()) { - const chunks = delayMs > 0 ? chunkAndroidInputText(part, 1) : [part]; - for (const [chunkIndex, chunk] of chunks.entries()) { - if (chunk) { - await sendAndroidImeHelperText(adb, packageName, chunk); - dispatchedSteps += 1; - } - if (delayMs > 0 && (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length)) { - await sleep(delayMs); - } - } - if (partIndex + 1 < parts.length) { - await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); - dispatchedSteps += 1; - } - } - } catch (error) { - throw discloseDispatchAfterSteps(error, dispatchedSteps); - } + await sendAndroidTextSteps( + device, + planAndroidTextSteps(text, delayMs > 0 ? 1 : Infinity, delayMs), + { + delayMs, + sendChunk: async (chunk) => await sendAndroidImeHelperText(adb, packageName, chunk), + }, + ); emitAndroidTextDiagnostic('type', 'test-ime', text); } @@ -294,32 +280,74 @@ async function typeAndroidShell( device: DeviceInfo, options: { action: AndroidTextInputAction; text: string; chunkSize: number; delayMs: number }, ): Promise { - const parts = options.text.split('\n'); - let dispatchedSteps = 0; - try { - for (const [partIndex, part] of parts.entries()) { - const chunks = chunkAndroidInputText(part, options.chunkSize); - for (const [chunkIndex, chunk] of chunks.entries()) { - if (chunk) { + await sendAndroidTextSteps( + device, + planAndroidTextSteps(options.text, options.chunkSize, options.delayMs), + { + delayMs: options.delayMs, + sendChunk: async (chunk) => { + try { await typeAndroidShellChunk(device, chunk); - dispatchedSteps += 1; - } - if ( - options.delayMs > 0 && - (chunkIndex + 1 < chunks.length || partIndex + 1 < parts.length) - ) { - await sleep(options.delayMs); + } catch (error) { + throw discloseAdbInputDispatch(error); } + }, + }, + ); + emitAndroidTextDiagnostic(options.action, 'adb-shell', options.text); +} + +/** + * One step of multi-line text entry: a chunk of one line (empty for an empty line, which sends + * nothing), or the ENTER keyevent between lines. A chunk pauses after itself unless it ends the text. + */ +type AndroidTextStep = { kind: 'chunk'; text: string; pauseAfter: boolean } | { kind: 'enter' }; + +function planAndroidTextSteps(text: string, chunkSize: number, delayMs: number): AndroidTextStep[] { + const parts = text.split('\n'); + return parts.flatMap((part, partIndex) => { + const chunks = chunkAndroidInputText(part, chunkSize); + const lastPart = partIndex + 1 === parts.length; + const chunkSteps = chunks.map((chunk, chunkIndex): AndroidTextStep => ({ + kind: 'chunk', + text: chunk, + pauseAfter: delayMs > 0 && !(lastPart && chunkIndex + 1 === chunks.length), + })); + return lastPart ? chunkSteps : [...chunkSteps, { kind: 'enter' }]; + }); +} + +/** Each non-empty chunk and each ENTER is one dispatched step of the series. */ +async function sendAndroidTextSteps( + device: DeviceInfo, + steps: readonly AndroidTextStep[], + options: { delayMs: number; sendChunk: (chunk: string) => Promise }, +): Promise { + let dispatchedSteps = 0; + try { + for (const step of steps) { + if (step.kind === 'enter') { + await pressAndroidEnterKey(device); + dispatchedSteps += 1; + continue; } - if (partIndex + 1 < parts.length) { - await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); + if (step.text) { + await options.sendChunk(step.text); dispatchedSteps += 1; } + if (step.pauseAfter) await sleep(options.delayMs); } } catch (error) { - throw discloseDispatchAfterSteps(discloseAdbInputDispatch(error), dispatchedSteps); + throw discloseDispatchAfterSteps(error, dispatchedSteps); + } +} + +async function pressAndroidEnterKey(device: DeviceInfo): Promise { + try { + await runAndroidShell(device, ['input', 'keyevent', 'ENTER']); + } catch (error) { + throw discloseAdbInputDispatch(error); } - emitAndroidTextDiagnostic(options.action, 'adb-shell', options.text); } async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise { diff --git a/src/daemon/interaction/internal/interaction-touch-fill.ts b/src/daemon/interaction/internal/interaction-touch-fill.ts index 6e00f1f941..f30d9551b9 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -107,15 +107,10 @@ async function admitFill( async function readFillAdmission( params: FillParams, ): Promise<{ response: DaemonResponse } | { admitted: AdmittedFill }> { - const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); - if (session) { - const unsupportedSurfaceResponse = unsupportedMacOsDesktopSurfaceInteraction(session, 'fill'); - if (unsupportedSurfaceResponse) return { response: unsupportedSurfaceResponse }; - } - if (!session) return { response: noActiveSessionError() }; - const parsedTarget = parseFillTarget(req.positionals ?? []); - if (!parsedTarget.ok) return { response: parsedTarget.response }; + const { req } = params; + const target = readFillTarget(params); + if ('response' in target) return target; + const { session, parsedTarget } = target; const prepared = await prepareTouchDispatch( params, session, @@ -147,6 +142,18 @@ async function readFillAdmission( }; } +function readFillTarget( + params: FillParams, +): { response: DaemonResponse } | Pick { + const session = params.sessionStore.get(params.sessionName); + if (!session) return { response: noActiveSessionError() }; + const unsupportedSurfaceResponse = unsupportedMacOsDesktopSurfaceInteraction(session, 'fill'); + if (unsupportedSurfaceResponse) return { response: unsupportedSurfaceResponse }; + const parsedTarget = parseFillTarget(params.req.positionals ?? []); + if (!parsedTarget.ok) return { response: parsedTarget.response }; + return { session, parsedTarget }; +} + // The fill @ref preamble shared with the press path's shape: read staleness // relative to what the client knew BEFORE any internal recapture, validate // @ref-incompatible flags, enforce iOS mutation freshness, and run the Android From dccf331473904433fb169558194bb5c597e9d15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 16:16:13 +0200 Subject: [PATCH 17/42] chore(gates): re-derive the DispatchDisclosure wire digest for the two-valued type `DispatchDisclosure` is new on this branch (unreleased, so no ack), and its declaration changed to `'no' | 'unknown'`; the digest is the one test/wire-compat/wire-compat.test.ts prints from declaration-digest.ts. The DaemonError and NormalizedError acks keep their digests; their rationale now names the two values. --- test/wire-compat/ledger.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 4d63a25cf1..8730eed40e 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -43,7 +43,7 @@ "packages/kernel/src/device.ts#PlatformSelector": "sha256:61de3f003507ea2f53b396b0146c17670bf674a2db2132e19c462ca6c10cc8fd", "packages/kernel/src/errors.ts#DaemonError": "sha256:d6cb31f516102d147bd4e4058ea4ace1d48584280f5b49205234c6dd5ada6e1c", "packages/kernel/src/errors.ts#DiagnosticsRecordRef": "sha256:c5ef4185d01814fbfddcdcf797ce892f6ebfe1fd3c3fa73c7560ce2d3b6fce60", - "packages/kernel/src/errors.ts#DispatchDisclosure": "sha256:c1ec9dcec06b23bb3b001fd03d6211d432b9635049d9ca2369a825c5a2898e8e", + "packages/kernel/src/errors.ts#DispatchDisclosure": "sha256:60ec96fe63fb8756d7936187bef6226df1ed56d48ff03f254581b3685e3aa4ce", "packages/kernel/src/errors.ts#ErrorCause": "sha256:2f38679b0e9ec997fe8d94b5d5025404fd6ebb21f2c91ed2ab5d4133e578dfc4", "packages/kernel/src/errors.ts#ErrorWireDetails": "sha256:79169f66b0935b8140c0e8329a8cdfd1db368fdadf34deeed404ea5ae7b6afe0", "packages/kernel/src/errors.ts#NormalizeErrorContext": "sha256:98567378fc456335c8751474152edcf989e676ad0d9f4b54afdd8e6a96b03ae2", @@ -227,12 +227,12 @@ { "declaration": "packages/kernel/src/errors.ts#DaemonError", "digest": "sha256:d6cb31f516102d147bd4e4058ea4ace1d48584280f5b49205234c6dd5ada6e1c", - "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | yes | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." + "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." }, { "declaration": "packages/kernel/src/errors.ts#NormalizedError", "digest": "sha256:99d880cce96cf364c4937045c704f2e34a870fef37af35c2115df33f8e8355af", - "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | yes | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." + "rationale": "details.dispatched is a new OPTIONAL field inside details, which was already an open record: an older peer ignores the key and every existing field keeps its shape. It carries no | unknown for whether a failed interaction reached the device (the dispatch-disclosure golden table)." }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody", From fda1d4f59d187398eb353f27c44d1b9b7b7e4b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 17:29:38 +0200 Subject: [PATCH 18/42] fix(errors): a failure after the mutation was sent never keeps a producer's no `dispatched: no` may describe a failure only while nothing reached the device. Two owners now record each mutation send that returned and downgrade any later failure of the same command through the kernel rule (`no` -> `unknown`, sent count added to `details.dispatchedSteps`): - Daemon: a request-scoped `RequestDispatchLedger`. The sends are the ADR 0014 side-effect seams of the interaction route (every `expireRefFrame` there): the runtime backend touch members and `performGesture` (interaction-runtime.ts), the direct iOS selector tap, and `type`. `discloseRequestDispatch` reads the ledger. The swipe series counter is superseded by the ledger and removed. - Maestro port: every `MaestroPublicOperation` kind is classified in `MAESTRO_OPERATION_MUTATES` (exhaustive record); `invokeMaestroPublicOperation` counts a mutating send that returned ok, and each port operation (one Maestro command) discloses the sends it made. This replaces the scrollUntilVisible counter, which counted only after the settle, and also covers inputText, eraseText, and tapOn retries. Post-dispatch phases enumerated: `grep -rn expireRefFrame src/daemon/interaction` (4 send seams); after each: afterRun escape guard, Android dialog readiness after-phase, response payload build, finalize. The runtime `--settle`/`--verify` observation (post-action-observation.ts, settle.ts) is best-effort and never throws, so it cannot carry a `no` out; the escape guard's foreground read can. Rows: daemon.post-dispatch.press-then-foreground-read-refused, maestro-port.scroll-until.capture-after-scroll-refused, maestro-port.input-text.settle-capture-refused, maestro-port.scroll-until.first-capture-refused (control: no send, no stands). Mutations: dropping the ledger increment in `sendRecordedMutation` fails the press and swipe rows; dropping the increment in `invokeMaestroPublicOperation` fails both maestro-port unknown rows. --- contracts/fixtures/dispatch-disclosure.json | 24 ++++ .../0011-interaction-guarantee-contract.md | 9 +- .../src/dispatch-disclosure.fixtures.ts | 2 + packages/kernel/src/errors.ts | 27 +++- .../__tests__/daemon-runtime-port.test.ts | 136 ++++++++++++++++++ .../daemon-runtime-port-observation.ts | 58 ++++---- .../daemon-runtime-port-support.ts | 11 +- .../src/daemon-port/daemon-runtime-port.ts | 33 ++++- .../daemon-runtime-public-operation.ts | 22 +++ .../interaction-dispatch-disclosure.test.ts | 43 +++++- .../interaction-dispatch-disclosure.ts | 38 +---- .../internal/interaction-gesture.ts | 24 ++-- .../internal/interaction-runtime.ts | 21 ++- .../internal/interaction-touch-direct-ios.ts | 7 +- .../interaction/internal/interaction.ts | 22 ++- src/daemon/interaction/internal/types.ts | 4 + src/daemon/request-dispatch-disclosure.ts | 71 +++++++++ 17 files changed, 442 insertions(+), 110 deletions(-) create mode 100644 src/daemon/request-dispatch-disclosure.ts diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 15b99ef6db..242b65a645 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -41,6 +41,12 @@ "trigger": "swipe --count 2: the first repetition ran and the second failed with a producer verdict of no; the series reports unknown with details.dispatchedSteps 1", "dispatched": "unknown" }, + { + "id": "daemon.post-dispatch.press-then-foreground-read-refused", + "producer": "daemon", + "trigger": "press returned from the device, then the post-press foreground read failed with a producer verdict of no; the request reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, { "id": "maestro-direct.fallback.pre-send-refusal", "producer": "daemon", @@ -55,6 +61,24 @@ "dispatched": "unknown", "fallsBack": false }, + { + "id": "maestro-port.scroll-until.capture-after-scroll-refused", + "producer": "maestro-port", + "trigger": "scrollUntilVisible sent its scroll and the settle capture after it was refused with RUNNER_BUSY (no); the Maestro command reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, + { + "id": "maestro-port.input-text.settle-capture-refused", + "producer": "maestro-port", + "trigger": "inputText typed its text and the settle capture after it was refused with RUNNER_BUSY (no); the Maestro command reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, + { + "id": "maestro-port.scroll-until.first-capture-refused", + "producer": "maestro-port", + "trigger": "scrollUntilVisible's first capture, before any scroll, was refused with RUNNER_BUSY; nothing was sent, so the producer's no stands", + "dispatched": "no" + }, { "id": "post-action-guard.android-press-left-app", "producer": "post-action-guard", diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 8686517d8e..1d64b3cbeb 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -159,9 +159,12 @@ that refuses before dispatch (target resolution, admission, a runner pre-send refusal) may say `no`; no producer can prove execution on its failure path, so there is no third value. The daemon fills `unknown` once, around interaction dispatch, for a failure no producer -classified, and never overwrites a producer's value: a wrong `no` makes a -consumer resend an action that already ran, while a wrong `unknown` only costs -an observation. The one exception is a read-only command (registry +classified. It keeps a producer's value until a mutation of the same request was +sent: after that, a later failure (a post-action read, a settle capture, a later +sub-step) is `unknown` with the sent count in `details.dispatchedSteps`, because a +wrong `no` makes a consumer resend an action that already ran, while a wrong +`unknown` only costs an observation. The Maestro port applies the same rule per +Maestro command. The one exception is a read-only command (registry `recordingEffect: 'observes-app'`): the daemon sets `no` over any producer value, because a read has no side effect and is always safe to resend. Each row without `implementedBy` names its driver file by id prefix, and that file drives diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index f6d828931a..663b0c31fc 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -10,6 +10,7 @@ export const DISPATCH_DISCLOSURE_PRODUCERS = [ 'android-helper', 'webdriver', 'post-action-guard', + 'maestro-port', ] as const; export type DispatchDisclosureProducer = (typeof DISPATCH_DISCLOSURE_PRODUCERS)[number]; @@ -54,6 +55,7 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'packages/platform-android/src/__tests__/touch-helper-session.test.ts', 'maestro-direct.': 'src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts', + 'maestro-port.': 'packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts', }; /** The per-row driving loop every driver file carries. */ diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 697ada3324..1aecf26cef 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -574,12 +574,35 @@ export function discloseDispatch( */ export function discloseDispatchAfterSteps(error: unknown, dispatchedSteps: number): unknown { if (dispatchedSteps === 0 || !(error instanceof AppError)) return error; - const innerSteps = error.details?.dispatchedSteps; return discloseDispatch(error, 'unknown', { - dispatchedSteps: dispatchedSteps + (typeof innerSteps === 'number' ? innerSteps : 0), + dispatchedSteps: stepsIncludingFailingStep(error.details, dispatchedSteps), }); } +/** + * {@link discloseDispatchAfterSteps} for a failure already on the wire: the details a failed + * response carries after `dispatchedSteps` device-reaching steps of its request returned. + */ +export function detailsAfterDispatchedSteps( + details: ErrorWireDetails | undefined, + dispatchedSteps: number, +): ErrorWireDetails | undefined { + if (dispatchedSteps === 0) return details; + return { + ...details, + dispatchedSteps: stepsIncludingFailingStep(details, dispatchedSteps), + dispatched: 'unknown', + }; +} + +function stepsIncludingFailingStep( + details: Record | undefined, + dispatchedSteps: number, +): number { + const innerSteps = details?.dispatchedSteps; + return dispatchedSteps + (typeof innerSteps === 'number' ? innerSteps : 0); +} + /** The side-effect seam's verdict, recorded only when no producer classified the failure. */ export function discloseUnclassifiedDispatch( error: Failure, diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts index 77af925b8b..d0aadbfcdc 100644 --- a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts @@ -20,6 +20,12 @@ import { } from './daemon-runtime-port-fixtures.ts'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; import { formatRole } from '@agent-device/kernel/snapshot'; +import { AppError } from '@agent-device/kernel/errors'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; test('registers Maestro inputText as sensitive before nested platform work', async () => { const root = mkdtempForTestSync('agent-device-maestro-input-diagnostics-'); @@ -792,3 +798,133 @@ test('timed-out waitForAnimationToEnd retains the pending hierarchy settle', asy 'click', ]); }); + +// contracts/fixtures/dispatch-disclosure.json, maestro-port rows: each drives one Maestro command +// through the real port with only the daemon invoke faked. + +const RUNNER_BUSY_SNAPSHOT_REFUSAL = { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'runner busy', + details: { + reason: 'runner_busy', + runnerErrorCode: 'RUNNER_BUSY', + retriable: true, + dispatched: 'no', + }, + }, +} as const; + +const DISCOVER_OFFSCREEN_SNAPSHOT = { + ok: true, + data: { + createdAt: 0, + nodes: [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } }, + { + index: 1, + parentIndex: 0, + type: 'Text', + label: 'Discover', + rect: { x: 20, y: 900, width: 120, height: 48 }, + }, + ], + }, +} as const; + +async function executeWithInvoke( + command: Parameters['execute']>[0]['command'], + invoke: MaestroDaemonOperationInvoke, +): Promise { + const port = createDaemonMaestroRuntimePort({ + ...makeRuntimeEnvelope({ flags: { platform: 'ios', replayBackend: 'maestro' } }), + invoke, + dependencies: makeDependencies(), + platform: 'ios', + }); + return await port.execute({ command, generation: 0, env: {}, invalidateObservation() {} }); +} + +const SCROLL_UNTIL_DISCOVER = { + kind: 'scrollUntilVisible', + source: { line: 2 }, + element: { text: 'Discover' }, + direction: 'up', + timeout: 2_000, +} as const; + +async function scrollUntilWithCaptureRefusedAfterScroll(): Promise { + const commands: string[] = []; + try { + return await executeWithInvoke(SCROLL_UNTIL_DISCOVER, async (request) => { + commands.push(request.command); + if (request.command !== 'snapshot') return { ok: true, data: {} }; + return commands.includes('scroll') + ? RUNNER_BUSY_SNAPSHOT_REFUSAL + : DISCOVER_OFFSCREEN_SNAPSHOT; + }); + } finally { + expect(commands.filter((command) => command === 'scroll')).toHaveLength(1); + } +} + +async function scrollUntilWithFirstCaptureRefused(): Promise { + const commands: string[] = []; + try { + return await executeWithInvoke(SCROLL_UNTIL_DISCOVER, async (request) => { + commands.push(request.command); + return request.command === 'snapshot' ? RUNNER_BUSY_SNAPSHOT_REFUSAL : { ok: true, data: {} }; + }); + } finally { + expect(commands).not.toContain('scroll'); + } +} + +async function inputTextWithSettleCaptureRefused(): Promise { + const commands: string[] = []; + try { + return await executeWithInvoke( + { kind: 'inputText', source: { line: 2 }, text: 'hello' }, + async (request) => { + commands.push(request.command); + return request.command === 'snapshot' + ? RUNNER_BUSY_SNAPSHOT_REFUSAL + : { ok: true, data: {} }; + }, + ); + } finally { + expect(commands[0]).toBe('type'); + } +} + +const DRIVERS: Record Promise> = { + 'maestro-port.scroll-until.capture-after-scroll-refused': + scrollUntilWithCaptureRefusedAfterScroll, + 'maestro-port.input-text.settle-capture-refused': inputTextWithSettleCaptureRefused, + 'maestro-port.scroll-until.first-capture-refused': scrollUntilWithFirstCaptureRefused, +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every maestro-port dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DRIVERS[row.id]; + expect(drive, `no driver for ${row.id}`).toBeDefined(); + const failure = await drive!().then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AppError); + const details = (failure as AppError).details; + expect(details?.dispatched).toBe(row.dispatched); + expect(details?.dispatchedSteps).toBe(row.dispatched === 'unknown' ? 1 : undefined); + }); +} diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts b/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts index 5e48897d3b..f6ce5ffd6d 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port-observation.ts @@ -1,8 +1,4 @@ -import { - createRequestCanceledError, - AppError, - discloseDispatchAfterSteps, -} from '@agent-device/kernel/errors'; +import { createRequestCanceledError, AppError } from '@agent-device/kernel/errors'; import { createHash } from 'node:crypto'; import { literalFromMaestroRegex, @@ -192,39 +188,33 @@ export async function scrollUntilTypedMaestroTarget(params: { const deadline = params.dependencies.now() + params.timeoutMs; let lastMatch: MaestroTargetMatch | undefined; let settledSnapshot: SnapshotState | undefined; - let dispatchedScrolls = 0; - try { - while (true) { - throwIfAborted(params.context.signal); - const snapshot = settledSnapshot ?? (await captureRetriableMaestroSnapshot(params, deadline)); - settledSnapshot = undefined; - lastMatch = resolveTargetFromSnapshot({ - query: { selector: params.selector }, - context: params.context, - snapshot, - platform: params.platform, - mode: 'observe', - }); - if ( - lastMatch.visiblePercentage === MAESTRO_RUNTIME_ADAPTER_POLICY.scrollUntilVisiblePercentage - ) { - return lastMatch; - } - if (params.dependencies.now() >= deadline) break; - - const remaining = deadline - params.dependencies.now(); - if (remaining > 0) { - settledSnapshot = await params.scroll(remaining, snapshot); - dispatchedScrolls += 1; - } + while (true) { + throwIfAborted(params.context.signal); + const snapshot = settledSnapshot ?? (await captureRetriableMaestroSnapshot(params, deadline)); + settledSnapshot = undefined; + lastMatch = resolveTargetFromSnapshot({ + query: { selector: params.selector }, + context: params.context, + snapshot, + platform: params.platform, + mode: 'observe', + }); + if ( + lastMatch.visiblePercentage === MAESTRO_RUNTIME_ADAPTER_POLICY.scrollUntilVisiblePercentage + ) { + return lastMatch; } + if (params.dependencies.now() >= deadline) break; - throwIfAborted(params.context.signal); - return requireObservationResult(lastMatch); - } catch (error) { - throw discloseDispatchAfterSteps(error, dispatchedScrolls); + const remaining = deadline - params.dependencies.now(); + if (remaining > 0) { + settledSnapshot = await params.scroll(remaining, snapshot); + } } + + throwIfAborted(params.context.signal); + return requireObservationResult(lastMatch); } export async function waitForTypedSnapshotStability(params: { diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts b/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts index 2d900593fd..c1ea8b5517 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts @@ -13,6 +13,7 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import type { DaemonMaestroRuntimeDependencies } from './daemon-runtime-port-observation.ts'; import { stripUndefined } from '@agent-device/kernel/record'; import { + isMaestroMutationOperation, projectMaestroPublicOperation, type MaestroDaemonOperationRequest, type MaestroPublicOperation, @@ -36,6 +37,9 @@ export type CreateDaemonMaestroRuntimeOperationsOptions = { readonly platform: Extract; }; +/** Mutating operations whose daemon request returned ok, counted across one port. */ +export type MaestroMutationLedger = { sent: number }; + type MaestroPublicOperationResult = Operation extends { kind: 'gestureViewport'; } @@ -43,7 +47,9 @@ type MaestroPublicOperationResult = Op : DaemonResponseData | undefined; export async function invokeMaestroPublicOperation( - options: CreateDaemonMaestroRuntimeOperationsOptions, + options: CreateDaemonMaestroRuntimeOperationsOptions & { + readonly mutationLedger?: MaestroMutationLedger; + }, operation: Operation, ): Promise> { const projected = projectMaestroPublicOperation(operation); @@ -51,6 +57,9 @@ export async function invokeMaestroPublicOperation MaestroRuntimeMetrics; recordSettle: (stable: StableMaestroSnapshot) => void; } { + const mutationLedger: MaestroMutationLedger = { sent: 0 }; + const options = { ...envelope, mutationLedger }; const snapshots = createDaemonMaestroSnapshotSource(options); const metrics: Omit = { screenshotCaptures: 0, @@ -355,13 +358,37 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper }), }; return { - operations, + operations: discloseDispatchAfterMutations(operations, mutationLedger), snapshots, readMetrics: () => ({ ...snapshots.readMetrics(), ...metrics }), recordSettle, }; } +/** + * Each operation is one Maestro command: once a mutation it sent returned, a later failure of that + * command (a settle capture, a retry, a verification read) is `unknown`, never a producer's `no`. + */ +function discloseDispatchAfterMutations( + operations: MaestroRuntimeOperations, + ledger: MaestroMutationLedger, +): MaestroRuntimeOperations { + const disclosed: Record = { ...operations }; + for (const [name, operation] of Object.entries(operations)) { + if (typeof operation !== 'function') continue; + const run = operation as (...args: unknown[]) => Promise; + disclosed[name] = async (...args: unknown[]) => { + const sentBefore = ledger.sent; + try { + return await run(...args); + } catch (error) { + throw discloseDispatchAfterSteps(error, ledger.sent - sentBefore); + } + }; + } + return disclosed as MaestroRuntimeOperations; +} + export function createDaemonMaestroRuntimePort( options: CreateDaemonMaestroRuntimeOperationsOptions, ): MaestroRuntimePort { diff --git a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts index 9016723661..724f791575 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts @@ -76,6 +76,28 @@ export type MaestroDaemonOperationRequest = { dispatch?: MaestroDaemonDispatchOptions; }; +const MAESTRO_OPERATION_MUTATES: Readonly> = { + launchApp: true, + stopApp: true, + clearState: true, + settingsPermission: true, + openLink: true, + typeText: true, + clickSelector: true, + clickPoint: true, + swipe: true, + scroll: true, + pressKey: true, + screenshot: false, + snapshot: false, + gestureViewport: false, +}; + +/** Whether the daemon request this operation projects to changes the device. */ +export function isMaestroMutationOperation(operation: MaestroPublicOperation): boolean { + return MAESTRO_OPERATION_MUTATES[operation.kind]; +} + export function projectMaestroPublicOperation( operation: MaestroPublicOperation, ): MaestroDaemonOperationRequest { diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 9b2d0147e7..658ede3a80 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -18,7 +18,11 @@ import { resetGetRuntimeFixture, } from '../../../__tests__/interaction-get-runtime-fixture.ts'; import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; -import { discloseInteractionDispatch } from '../interaction-dispatch-disclosure.ts'; +import { + createRequestDispatchLedger, + discloseRequestDispatch, +} from '../../../request-dispatch-disclosure.ts'; +import { clearAndroidObservationFixture } from '../../../__tests__/android-observation-fixture.ts'; import { handleInteractionCommands } from '../../index.ts'; import { assertAndroidPressStayedInApp } from '../interaction-android-escape.ts'; import { gestureRuntimeBindingsFixture } from './gesture-runtime-bindings.fixtures.ts'; @@ -101,8 +105,9 @@ async function pressThatLeftTheApp(): Promise { readAppState: async () => ({ package: 'com.android.settings' }), isPermissionPackage: async () => false, } as unknown as AndroidObservationAdapter; - return await discloseInteractionDispatch( + return await discloseRequestDispatch( { token: 't', session: session.name, command: 'press', positionals: ['@e1'] }, + createRequestDispatchLedger(), async () => { await assertAndroidPressStayedInApp(session, '@e1', observation); return null; @@ -148,12 +153,43 @@ async function swipeRefusedOnSecondRepetition(): Promise { throw new AppError(response.error.code, response.error.message, response.error.details); } +/** An Android press whose tap returns and whose post-press foreground read is refused with `no`. */ +async function pressThenForegroundReadRefused(): Promise { + const session = makeAndroidSession('dispatch-disclosure-post-dispatch', { + appBundleId: 'com.example.app', + }); + const sessionStore = makeSessionStore(); + sessionStore.set(session.name, session); + const readRefusal = new AppError('COMMAND_FAILED', 'adb device offline', { dispatched: 'no' }); + const androidObservation: AndroidObservationAdapter = { + ...clearAndroidObservationFixture, + readAppState: async () => { + if (mockTapPoint.mock.calls.length > 0) throw readRefusal; + return { package: 'com.example.app' }; + }, + }; + const response = await handleInteractionCommands({ + req: { token: 't', session: session.name, command: 'press', positionals: ['50', '40'] }, + sessionName: session.name, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + androidObservation, + }); + assert.equal(mockTapPoint.mock.calls.length, 1); + assert.ok(response && !response.ok, 'expected the press to fail after its tap'); + assert.equal(response.error.message, 'adb device offline'); + assert.equal(response.error.details?.dispatchedSteps, 1); + throw new AppError(response.error.code, response.error.message, response.error.details); +} + const DRIVERS: Record Promise> = { 'daemon.refusal.ref-not-found': () => refusedPress(['@e9']), 'daemon.refusal.admission': () => refusedPress([]), 'daemon.refusal.fill-admission': () => refusedFill(['@e1']), 'daemon.unclassified': () => pressAfterUnclassifiedTouchFailure(), 'daemon.series.swipe-later-repetition-refused': swipeRefusedOnSecondRepetition, + 'daemon.post-dispatch.press-then-foreground-read-refused': pressThenForegroundReadRefused, 'daemon.read-only-command': () => press({ command: 'get', positionals: ['text', 'label="Missing"'] }), 'post-action-guard.android-press-left-app': pressThatLeftTheApp, @@ -213,8 +249,9 @@ test('a read-only command discloses no over a producer verdict it throws', async dispatched: 'unknown', }); await assert.rejects( - discloseInteractionDispatch( + discloseRequestDispatch( { token: 't', session: 's', command: 'get', positionals: ['text', 'label="Missing"'] }, + createRequestDispatchLedger(), async () => { throw producerFailure; }, diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts index 35ae57189b..78d7884823 100644 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts @@ -1,39 +1,5 @@ -import { - type AppError, - asAppError, - discloseDispatch, - discloseUnclassifiedDispatch, -} from '@agent-device/kernel/errors'; -import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; -import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; - -/** - * The daemon's verdict around interaction dispatch. A request the registry declares read-only - * (`recordingEffect: 'observes-app'`) is `no` over any producer verdict: a read has no side effect, - * so it is always safe to resend. Otherwise `unknown` fills only a failure no producer classified. - */ -export async function discloseInteractionDispatch( - req: DaemonRequest, - dispatch: () => Promise, -): Promise { - const readOnly = resolveCommandRecordingEffect(req) === 'observes-app'; - try { - const response = await dispatch(); - if (!response || response.ok) return response; - const producerVerdict = response.error.details?.dispatched; - const dispatched = readOnly ? 'no' : (producerVerdict ?? 'unknown'); - if (dispatched === producerVerdict) return response; - return { - ok: false, - error: { ...response.error, details: { ...response.error.details, dispatched } }, - }; - } catch (error) { - const failure = asAppError(error); - throw readOnly - ? discloseDispatch(failure, 'no') - : discloseUnclassifiedDispatch(failure, 'unknown'); - } -} +import { type AppError, asAppError, discloseDispatch } from '@agent-device/kernel/errors'; +import type { DaemonResponse } from '../../daemon-request.ts'; /** A failure response built before any dispatch: the requested operation never reached the device. */ export function refusedBeforeDispatch( diff --git a/src/daemon/interaction/internal/interaction-gesture.ts b/src/daemon/interaction/internal/interaction-gesture.ts index 2f5218d4cc..7e4192bc89 100644 --- a/src/daemon/interaction/internal/interaction-gesture.ts +++ b/src/daemon/interaction/internal/interaction-gesture.ts @@ -17,7 +17,7 @@ import { SWIPE_REPETITION_MAX, SWIPE_SERIES_MAX_SCHEDULED_DURATION_MS, } from '@agent-device/contracts/scroll-gesture'; -import { AppError, discloseDispatchAfterSteps, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { REF_GRAMMAR_HINT, splitRefGenerationSuffix, @@ -374,20 +374,14 @@ async function runSwipeRepetitions( pattern: 'one-way' | 'ping-pong', ) { let result: Awaited> | undefined; - let dispatchedSwipes = 0; - try { - for (let index = 0; index < count; index += 1) { - const normalized = normalizePublicSwipeMotion(swipeMotionAtIndex(input, pattern, index)); - result = await runtime.interactions.gesture({ - session: params.sessionName, - requestId: params.req.meta?.requestId, - gesture: normalized.gesture, - }); - dispatchedSwipes += 1; - if (pauseMs > 0 && index + 1 < count) await sleep(pauseMs); - } - } catch (error) { - throw discloseDispatchAfterSteps(error, dispatchedSwipes); + for (let index = 0; index < count; index += 1) { + const normalized = normalizePublicSwipeMotion(swipeMotionAtIndex(input, pattern, index)); + result = await runtime.interactions.gesture({ + session: params.sessionName, + requestId: params.req.meta?.requestId, + gesture: normalized.gesture, + }); + if (pauseMs > 0 && index + 1 < count) await sleep(pauseMs); } if (!result) throw new Error('Swipe orchestration did not execute a gesture.'); return result; diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index 0ca084d4ad..271136ca79 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -17,6 +17,7 @@ import { confirmIosOffscreenTargetVisible } from '../../offscreen-target-probe.t import { createDaemonRuntimeSessionStore } from '../../runtime-session.ts'; import { expireRefFrame } from '../../ref-frame.ts'; import { setSessionSnapshot } from '../../session-snapshot.ts'; +import { sendRecordedMutation } from '../../request-dispatch-disclosure.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput, @@ -90,6 +91,7 @@ export function createInteractionRuntimeForRoute( pairedGestureViewport: params.pairedGestureViewport, touchExecutor: params.touchExecutor, gestures: params.gestures, + dispatchLedger: params.dispatchLedger, }); } @@ -145,7 +147,7 @@ function createInteractionBackend(params: InteractionRuntimeInput): AgentDeviceB await params.confirmOffscreenTargetVisible!(node, rootViewport), } : {}), - ...touchBackendMembers(params.touchExecutor, params.expireRefFrame, flags), + ...touchBackendMembers(params, flags), }; } @@ -165,21 +167,28 @@ function gestureBackendMembers( pairedGestureViewport ?? (await gestures.gestureViewport?.(gestureContext())), performGesture: async (_context, plan): Promise => { params.expireRefFrame(); - return toBackendActionResult(await gestures.performPlan(plan, gestureContext())); + return toBackendActionResult( + await sendRecordedMutation( + params.dispatchLedger, + async () => await gestures.performPlan(plan, gestureContext()), + ), + ); }, }; } function touchBackendMembers( - executor: InteractionRuntimeInput['touchExecutor'], - expireRefFrame: () => void, + params: InteractionRuntimeInput, flags: InteractionRuntimeInput['flags'], ): Partial { + const executor = params.touchExecutor; if (!executor) return {}; const { tapPoint, tapRef, fillPoint, fillRef, longPressPoint, hoverPoint, hoverRef } = executor; const run = async (action: () => unknown): Promise => { - expireRefFrame(); - return toBackendActionResult(await action()); + params.expireRefFrame(); + return toBackendActionResult( + await sendRecordedMutation(params.dispatchLedger, async () => await action()), + ); }; return { tap: tapPoint ? (_context, point) => run(() => tapPoint(point, flags)) : undefined, diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index 90a00fa6c9..7010eef56a 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -6,6 +6,7 @@ import { type DirectIosSelectorTarget, } from '../../direct-ios-selector.ts'; import { expireRefFrame } from '../../ref-frame.ts'; +import { sendRecordedMutation } from '../../request-dispatch-disclosure.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; import type { SessionState } from '../../session-state.ts'; import { finalizeTouchInteraction } from './interaction-runtime.ts'; @@ -43,7 +44,11 @@ export async function dispatchDirectIosSelectorTap( // not-found/timeout is post-seam and does not restore the frame. expireRefFrame(session); try { - const data = (await tapElementSelector(selector)) ?? {}; + const data = + (await sendRecordedMutation( + handlerParams.dispatchLedger, + async () => await tapElementSelector(selector), + )) ?? {}; const actionFinishedAt = Date.now(); const point = readPointFromDirectSelectorTapResult(data); const publicData = transformTouchResponseData({ diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 080a746dec..d0f1704c80 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -4,11 +4,15 @@ import { type RequestCaptureProof, withCaptureDisclosures } from '../../capture- import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { dispatchFillViaRuntime } from './interaction-touch-fill.ts'; import { dispatchTargetedTouchViaRuntime } from './interaction-touch-press.ts'; -import { discloseInteractionDispatch } from './interaction-dispatch-disclosure.ts'; import { finalizeTouchInteraction } from './interaction-runtime.ts'; import { refSnapshotFlagGuardResponse } from '../../ref-snapshot-flag-policy.ts'; import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../../selector-runtime.ts'; import { expireRefFrame } from '../../ref-frame.ts'; +import { + createRequestDispatchLedger, + discloseRequestDispatch, + sendRecordedMutation, +} from '../../request-dispatch-disclosure.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { normalizeError } from '@agent-device/kernel/errors'; import { @@ -26,9 +30,11 @@ export async function handleInteractionCommands( params: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession }, ): Promise { const captureProof: RequestCaptureProof = {}; - const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; - const response = await discloseInteractionDispatch( + const dispatchLedger = params.dispatchLedger ?? createRequestDispatchLedger(); + const routed = { ...params, refSnapshotFlagGuardResponse, captureProof, dispatchLedger }; + const response = await discloseRequestDispatch( params.req, + dispatchLedger, async () => await dispatchInteractionCommand(routed), ); return response @@ -154,9 +160,13 @@ async function runTypeTextViaRuntime( // executing so a later step cannot reuse it. R41: the bound executor already validates and // composes the retired leaf's exact result, so nothing here re-validates or re-formats it. expireRefFrame(session); - const result = await boundTypeText( - req.positionals ?? [], - params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), + const result = await sendRecordedMutation( + params.dispatchLedger, + async () => + await boundTypeText( + req.positionals ?? [], + params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), + ), ); await ensureAndroidBlockingSystemDialogReady({ session, diff --git a/src/daemon/interaction/internal/types.ts b/src/daemon/interaction/internal/types.ts index 9236285c0d..0e0b8a7fc0 100644 --- a/src/daemon/interaction/internal/types.ts +++ b/src/daemon/interaction/internal/types.ts @@ -2,6 +2,7 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { Rect, SnapshotPreferredBackend, SnapshotState } from '@agent-device/kernel/snapshot'; import type { RequestCaptureProof } from '../../capture-disclosure.ts'; +import type { RequestDispatchLedger } from '../../request-dispatch-disclosure.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { CommandSessionStore } from '../../../runtime-contract.ts'; import type { DeferredInteractionOutcomeMark } from '../../deferred-interaction-outcome.ts'; @@ -36,6 +37,8 @@ export type InteractionRouteInput = { * press consumes no capture and must not be disclosed against an earlier request's tree (#2682). */ captureProof?: RequestCaptureProof; + /** The request's sent mutations; a failure after one never keeps a producer's `no`. */ + dispatchLedger?: RequestDispatchLedger; }; export type FindRouteInput = { @@ -96,6 +99,7 @@ export type InteractionRuntimeInput = { pairedGestureViewport?: Rect; touchExecutor?: BoundTouchExecutor; gestures?: BoundGestureExecutor; + dispatchLedger?: RequestDispatchLedger; }; export type InteractionGestureVisualization = ( diff --git a/src/daemon/request-dispatch-disclosure.ts b/src/daemon/request-dispatch-disclosure.ts new file mode 100644 index 0000000000..74015af89c --- /dev/null +++ b/src/daemon/request-dispatch-disclosure.ts @@ -0,0 +1,71 @@ +import { + asAppError, + detailsAfterDispatchedSteps, + discloseDispatch, + discloseDispatchAfterSteps, + discloseUnclassifiedDispatch, + type ErrorWireDetails, +} from '@agent-device/kernel/errors'; +import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; +import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; + +/** + * The device-reaching mutations of one request whose send returned. Once one did, no later failure + * of that request may claim `dispatched: no`, whatever observation or sub-step produced it. + */ +export type RequestDispatchLedger = { dispatchedSteps: number }; + +export function createRequestDispatchLedger(): RequestDispatchLedger { + return { dispatchedSteps: 0 }; +} + +/** Sends one mutation and records it in the request's ledger once the send returned. */ +export async function sendRecordedMutation( + ledger: RequestDispatchLedger | undefined, + send: () => Promise, +): Promise { + const result = await send(); + if (ledger) ledger.dispatchedSteps += 1; + return result; +} + +/** + * The daemon's verdict around one request, keyed by the registry's `recordingEffect`. A read + * (`observes-app`) is `no` over any producer verdict: it has no side effect, so a resend is safe. + * Otherwise a producer verdict stands until a mutation of this request was sent, after which the + * failure is `unknown` with the sent count in `details.dispatchedSteps`; `unknown` fills a failure + * no producer classified. A command without a declared effect passes through. + */ +export async function discloseRequestDispatch( + req: DaemonRequest, + ledger: RequestDispatchLedger, + dispatch: () => Promise, +): Promise { + const effect = resolveCommandRecordingEffect(req); + try { + const response = await dispatch(); + if (!response || response.ok || effect === undefined) return response; + const details = disclosedDetails(effect, response.error.details, ledger); + if (details === response.error.details) return response; + return { ok: false, error: { ...response.error, details } }; + } catch (error) { + if (effect === undefined) throw error; + const failure = asAppError(error); + if (effect === 'observes-app') throw discloseDispatch(failure, 'no'); + discloseDispatchAfterSteps(failure, ledger.dispatchedSteps); + throw discloseUnclassifiedDispatch(failure, 'unknown'); + } +} + +function disclosedDetails( + effect: NonNullable>, + details: ErrorWireDetails | undefined, + ledger: RequestDispatchLedger, +): ErrorWireDetails | undefined { + if (effect === 'observes-app') { + return details?.dispatched === 'no' ? details : { ...details, dispatched: 'no' }; + } + const afterSteps = detailsAfterDispatchedSteps(details, ledger.dispatchedSteps); + if (afterSteps?.dispatched !== undefined) return afterSteps; + return { ...afterSteps, dispatched: 'unknown' }; +} From d3a9747cca7293fb3c78176440ce4420836263ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 17:33:17 +0200 Subject: [PATCH 19/42] fix(errors): composite and helper-IME steps disclose through the same helper A `no` from a later sub-step of one interactor operation no longer describes the whole operation. Composite enumeration: interactor operations that issue more than one device-reaching send whose producer can say `no`. The `no` producers are (grep "discloseDispatch(.*'no'"): adb input (`discloseAdbInputDispatch`), the Apple runner, and daemon refusals. Their multi-send operations: - Android `doubleTap` (src/core/interactors/android.ts): two `input tap` sends, uncounted. Now `doubleTapAndroid` in input-actions.ts discloses a failure of the second tap through `discloseDispatchAfterSteps(error, 1)`. - Android type/fill/clear, keyboard dismiss, Apple press series and sequence chunks: already counted on this branch. - Apple `doubleTap`, limrun and WebDriver `doubleTap`, HarmonyOS doubleClick: one send each. Linux doubleClick sends several xdotool/ydotool calls, but no Linux producer says `no`, so nothing can leak. Helper IME: every broadcast goes through `sendAndroidImeHelperBroadcast`, which now classifies its failure with `discloseAdbInputDispatch` (TOOL_MISSING before start is `no`, anything after start `unknown`), the same classifier the shell path uses. The classifier moves to adb-failure.ts so ime-helper.ts need not import the input-action module. Rows: android-adb.input-tap.double-tap-second-refused, android-helper.ime.broadcast-tool-missing, android-helper.ime.broadcast-failed. Mutations: rethrowing the raw error in `doubleTapAndroid` fails the double-tap row (`no`); removing the classification in the broadcast sender fails both IME rows (unset). --- contracts/fixtures/dispatch-disclosure.json | 18 ++++++++ .../src/__tests__/dispatch-disclosure.test.ts | 42 ++++++++++++++++++- packages/platform-android/src/adb-failure.ts | 8 +++- packages/platform-android/src/ime-helper.ts | 22 ++++++---- .../platform-android/src/input-actions.ts | 15 ++++--- packages/platform-android/src/mechanics.ts | 1 + packages/platform-android/src/text-input.ts | 3 +- src/core/interactors/android.ts | 6 +-- 8 files changed, 94 insertions(+), 21 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 242b65a645..6da58f8617 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -265,6 +265,12 @@ "trigger": "adb input tap started and failed", "dispatched": "unknown" }, + { + "id": "android-adb.input-tap.double-tap-second-refused", + "producer": "android-adb", + "trigger": "press --double: the first input tap ran and the second could not start (TOOL_MISSING, no); the double tap reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, { "id": "android-adb.input-text.failed-after-chunk", "producer": "android-adb", @@ -307,6 +313,18 @@ "trigger": "one-shot helper gesture failed before returning parseable output", "dispatched": "unknown" }, + { + "id": "android-helper.ime.broadcast-tool-missing", + "producer": "android-helper", + "trigger": "type through the helper IME: adb is missing, so the first text broadcast never started (TOOL_MISSING)", + "dispatched": "no" + }, + { + "id": "android-helper.ime.broadcast-failed", + "producer": "android-helper", + "trigger": "type through the helper IME: the text broadcast started and exited non-zero; the IME may have received it", + "dispatched": "unknown" + }, { "id": "android-helper.gesture-session.reported-failure", "producer": "android-helper", diff --git a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts index 089f16f60a..458fce8dfc 100644 --- a/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts +++ b/packages/platform-android/src/__tests__/dispatch-disclosure.test.ts @@ -14,7 +14,7 @@ import { type AndroidAdbExecutor, } from '../adb-executor.ts'; import { completeAndroidFillVerification } from '../fill-verification.ts'; -import { pressAndroid } from '../input-actions.ts'; +import { doubleTapAndroid, pressAndroid } from '../input-actions.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; import { fillAndroid, typeAndroid } from '../text-input.ts'; import { executeAndroidTouchHelperPlan } from '../touch-helper.ts'; @@ -68,6 +68,36 @@ async function tapWithAdbAnswer(answer: Error | { exitCode: number; stderr: stri ); } +async function doubleTapWithSecondTapRefused(): Promise { + let taps = 0; + await withFakeAdb( + (args) => { + if (!isShellInput(args, 'tap')) return undefined; + taps += 1; + return taps === 2 ? new AppError('TOOL_MISSING', 'adb not found in PATH') : undefined; + }, + async ({ device }) => await doubleTapAndroid(device, 10, 20), + ); + assert.equal(taps, 2); +} + +const HELPER_IME_ACTIVE = 'mInputShown=true mCurMethodId=com.callstack.agentdevice.imehelper/.Ime'; + +/** `type` on a device whose active input method is the helper IME, with the broadcast scripted. */ +async function typeThroughHelperIme(broadcast: Error | { exitCode: number; stderr: string }) { + let broadcasts = 0; + await withFakeAdb( + (args) => { + if (args.includes('dumpsys') && args.includes('input_method')) return HELPER_IME_ACTIVE; + if (!(args.includes('am') && args.includes('broadcast'))) return undefined; + broadcasts += 1; + return broadcast; + }, + async ({ device }) => await typeAndroid(device, 'filed'), + ); + assert.equal(broadcasts, 1); +} + async function typeFailingOnSecondChunk(): Promise { let textChunks = 0; await withFakeAdb( @@ -160,6 +190,16 @@ const DRIVERS: Record Promise; dispatchedSteps?: 'android-adb.input-tap.failed': { drive: () => tapWithAdbAnswer({ exitCode: 1, stderr: 'error: device offline' }), }, + 'android-adb.input-tap.double-tap-second-refused': { + drive: doubleTapWithSecondTapRefused, + dispatchedSteps: 1, + }, + 'android-helper.ime.broadcast-tool-missing': { + drive: () => typeThroughHelperIme(new AppError('TOOL_MISSING', 'adb not found in PATH')), + }, + 'android-helper.ime.broadcast-failed': { + drive: () => typeThroughHelperIme({ exitCode: 1, stderr: 'Broadcast failed' }), + }, 'android-adb.input-text.failed-after-chunk': { drive: typeFailingOnSecondChunk, dispatchedSteps: 1, diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts index 94fdd176fc..76839dade1 100644 --- a/packages/platform-android/src/adb-failure.ts +++ b/packages/platform-android/src/adb-failure.ts @@ -146,7 +146,7 @@ export function classifyAndroidAdbFailure( return undefined; } -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import { isCommandTimeoutError } from '@agent-device/host-kit/command'; import type { HostCommandResult } from '@agent-device/contracts/platform-runtime-host'; import type { AndroidAdbExecutorResult } from './adb-transport.ts'; @@ -281,3 +281,9 @@ export function attachAndroidDiscoveryTimeout(error: T): T { }; return error; } + +/** One adb input send's failure: adb that never started delivered nothing; otherwise it may have. */ +export function discloseAdbInputDispatch(error: unknown): unknown { + if (!(error instanceof AppError)) return error; + return discloseDispatch(error, error.code === 'TOOL_MISSING' ? 'no' : 'unknown'); +} diff --git a/packages/platform-android/src/ime-helper.ts b/packages/platform-android/src/ime-helper.ts index 417ad45ef9..3e2b77ad42 100644 --- a/packages/platform-android/src/ime-helper.ts +++ b/packages/platform-android/src/ime-helper.ts @@ -10,7 +10,7 @@ import { type AndroidImeHelperManifest, } from './helper-artifacts.ts'; import type { ShellWord } from '@agent-device/kernel/device-shell'; -import { androidAdbResultError } from './adb-failure.ts'; +import { androidAdbResultError, discloseAdbInputDispatch } from './adb-failure.ts'; import { runAdbShell } from './adb-executor.ts'; import type { AndroidAdbExecutor, AndroidAdbProvider } from './adb-transport.ts'; import { requireAndroidAdbHost } from './adb-host.ts'; @@ -174,14 +174,18 @@ async function sendAndroidImeHelperBroadcast( for (const [key, value] of Object.entries(extras)) { words.push('--es', key, value); } - const result = await runAdbShell(adb, words, { - allowFailure: true, - timeoutMs: ANDROID_IME_HELPER_BROADCAST_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - throw androidAdbResultError('Android IME helper broadcast failed', result, { - action, - packageName, + try { + const result = await runAdbShell(adb, words, { + allowFailure: true, + timeoutMs: ANDROID_IME_HELPER_BROADCAST_TIMEOUT_MS, }); + if (result.exitCode !== 0) { + throw androidAdbResultError('Android IME helper broadcast failed', result, { + action, + packageName, + }); + } + } catch (error) { + throw discloseAdbInputDispatch(error); } } diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index 751da37ca7..7c11aae251 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -17,10 +17,11 @@ import { } from '@agent-device/contracts/scroll-gesture'; import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; +import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { sleep } from '@agent-device/host-kit/retry'; import { runAndroidShell } from './adb.ts'; +import { discloseAdbInputDispatch } from './adb-failure.ts'; import { executeAndroidTouchPlan, readAndroidGestureViewportReading } from './touch-executor.ts'; import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; @@ -32,10 +33,14 @@ export async function pressAndroid(device: DeviceInfo, x: number, y: number): Pr } } -/** One `adb shell input` failure: adb that never started delivered nothing; otherwise it may have. */ -export function discloseAdbInputDispatch(error: unknown): unknown { - if (!(error instanceof AppError)) return error; - return discloseDispatch(error, error.code === 'TOOL_MISSING' ? 'no' : 'unknown'); +/** Two `input tap` sends; a failure of the second follows a tap that already landed. */ +export async function doubleTapAndroid(device: DeviceInfo, x: number, y: number): Promise { + await pressAndroid(device, x, y); + try { + await pressAndroid(device, x, y); + } catch (error) { + throw discloseDispatchAfterSteps(error, 1); + } } export async function pressAndroidTvRemote( diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 96efabf755..26cce50693 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -179,6 +179,7 @@ export { export { appSwitcherAndroid, backAndroid, + doubleTapAndroid, focusAndroid, getAndroidScreenSize, homeAndroid, diff --git a/packages/platform-android/src/text-input.ts b/packages/platform-android/src/text-input.ts index df47670102..138bc05671 100644 --- a/packages/platform-android/src/text-input.ts +++ b/packages/platform-android/src/text-input.ts @@ -31,7 +31,8 @@ import { sendAndroidImeHelperText, } from './ime-helper.ts'; import { isAndroidTestImeActive } from './ime-lifecycle.ts'; -import { discloseAdbInputDispatch, focusAndroid } from './input-actions.ts'; +import { discloseAdbInputDispatch } from './adb-failure.ts'; +import { focusAndroid } from './input-actions.ts'; import type { AndroidHelperSessionOptions } from './snapshot-helper-types.ts'; /** diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 84e1684ac5..b725b3850a 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -14,6 +14,7 @@ import { longPressAndroid, openAndroidApp, openAndroidDevice, + doubleTapAndroid, pressAndroid, pressAndroidEnter, pressAndroidTvRemote, @@ -70,10 +71,7 @@ export function createAndroidInteractor( openDevice: () => openAndroidDevice(device), close: (app) => closeAndroidApp(device, app), tap: (x, y) => pressAndroid(device, x, y), - doubleTap: async (x, y) => { - await pressAndroid(device, x, y); - await pressAndroid(device, x, y); - }, + doubleTap: (x, y) => doubleTapAndroid(device, x, y), longPress: (x, y, durationMs) => longPressAndroid(device, x, y, durationMs), focus: (x, y) => focusAndroid(device, x, y), type: (text, delayMs) => typeAndroid(device, text, delayMs), From ebc58a406dc5956820fae736d4a9fb338f40af9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 18:04:06 +0200 Subject: [PATCH 20/42] fix(daemon): every mutating command route discloses dispatch The dispatch seam was wired only into the interaction route, so a generic `scroll` (and every other non-interaction command) could fail with `dispatched` unset. The request router now owns the seam: `executeLockedRequest` wraps the handler chain and the generic dispatcher in `discloseRequestDispatch`, keyed by the registry `recordingEffect` (resolveCommandRecordingEffect): `observes-app` is `no`; `mutates-app` keeps a producer verdict, downgrades a `no` after a sent mutation, and fills `unknown` otherwise; a command without a declared effect passes through. `handleInteractionCommands` no longer wraps itself, so the seam runs once per request (a second pass would double the ledger count). The router also owns the ledger: it passes it through the handler chain to the interaction route and records the generic route's bound `execute` as its mutation send. A generic admission refusal is `no`, like the interaction admission refusals; the pre-dispatch helpers move next to the seam. `scroll --until` now proves its own pre-gesture refusals: a failure before the first gesture, other than the gesture send's own, is `no`. Residual: failures before the router seam (session lookup, lock, lease admission in prepareLockedRequestScope) stay unset. Commands outside the interaction and generic routes (open, close, install, settings, alert, react-native) get the fill but record no ledger sends; their post-mutation steps can still carry a producer `no` if one appears there. Rows (src/daemon/__tests__/request-dispatch-disclosure.test.ts, real router): daemon.route.scroll-transport-failure, daemon.route.scroll-until-before-first-gesture, daemon.route.scroll-then-dialog-read-refused, daemon.route.read-only-get. Mutations: routing without `discloseRequestDispatch` fails the scroll, Android scroll, and get rows; dropping the ledger wrap on `execute` fails the Android scroll row; rethrowing in scroll-until fails the --until row. --- contracts/fixtures/dispatch-disclosure.json | 24 +++ .../0011-interaction-guarantee-contract.md | 26 +-- .../src/dispatch-disclosure.fixtures.ts | 1 + .../request-dispatch-disclosure.test.ts | 151 ++++++++++++++++++ .../interaction-dispatch-disclosure.test.ts | 23 ++- .../interaction-dispatch-disclosure.ts | 18 --- .../internal/interaction-touch-fill.ts | 2 +- .../internal/interaction-touch-prepare.ts | 2 +- .../interaction-touch-press-admission.ts | 2 +- .../interaction/internal/interaction.ts | 15 +- src/daemon/request-dispatch-disclosure.ts | 25 ++- src/daemon/request-handler-chain.ts | 3 + src/daemon/request-router.ts | 39 ++++- src/daemon/scroll-until.ts | 12 +- website/docs/docs/commands.md | 2 +- 15 files changed, 284 insertions(+), 61 deletions(-) create mode 100644 src/daemon/__tests__/request-dispatch-disclosure.test.ts delete mode 100644 src/daemon/interaction/internal/interaction-dispatch-disclosure.ts diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 6da58f8617..7c043ef626 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -47,6 +47,30 @@ "trigger": "press returned from the device, then the post-press foreground read failed with a producer verdict of no; the request reports unknown with details.dispatchedSteps 1", "dispatched": "unknown" }, + { + "id": "daemon.route.scroll-transport-failure", + "producer": "daemon", + "trigger": "a plain scroll (generic route) whose gesture send failed with no producer verdict; the request router fills unknown for a command the registry declares mutates-app", + "dispatched": "unknown" + }, + { + "id": "daemon.route.scroll-until-before-first-gesture", + "producer": "daemon", + "trigger": "scroll --until whose first capture is refused before any gesture was sent; scroll-until proves nothing was sent", + "dispatched": "no" + }, + { + "id": "daemon.route.scroll-then-dialog-read-refused", + "producer": "daemon", + "trigger": "an Android scroll whose gesture returned, then the post-command dialog read failed with a producer verdict of no; the request reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, + { + "id": "daemon.route.read-only-get", + "producer": "daemon", + "trigger": "get through the request router: the router, not only the interaction route, discloses no for a command the registry declares observes-app", + "dispatched": "no" + }, { "id": "maestro-direct.fallback.pre-send-refusal", "producer": "daemon", diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 1d64b3cbeb..e3b6cd9544 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -157,19 +157,19 @@ operation provably never reached the device, so a resend is safe) and `unknown` (it may have landed, so observe before resending). Only a producer that refuses before dispatch (target resolution, admission, a runner pre-send refusal) may say `no`; no producer can prove execution on its failure path, so -there is no third value. The daemon fills -`unknown` once, around interaction dispatch, for a failure no producer -classified. It keeps a producer's value until a mutation of the same request was -sent: after that, a later failure (a post-action read, a settle capture, a later -sub-step) is `unknown` with the sent count in `details.dispatchedSteps`, because a -wrong `no` makes a consumer resend an action that already ran, while a wrong -`unknown` only costs an observation. The Maestro port applies the same rule per -Maestro command. The one exception is a read-only command (registry -`recordingEffect: 'observes-app'`): the daemon sets `no` over any producer -value, because a read has no side effect and is always safe to resend. Each row -without `implementedBy` names its driver file by id prefix, and that file drives -the real producer; a row marked `implementedBy` waits for the branch that ships -it. +there is no third value. The daemon's request router fills `unknown` once, +around every routed command the registry declares `recordingEffect: 'mutates- +app'`, for a failure no producer classified. It keeps a producer's value until +a mutation of the same request was sent: after that, a later failure (a post- +action read, a settle capture, a later sub-step) is `unknown` with the sent +count in `details.dispatchedSteps`, because a wrong `no` makes a consumer +resend an action that already ran, while a wrong `unknown` only costs an +observation. The Maestro port applies the same rule per Maestro command. The +one exception is a read-only command (registry `recordingEffect: 'observes- +app'`): the daemon sets `no` over any producer value, because a read has no +side effect and is always safe to resend. Each row without `implementedBy` +names its driver file by id prefix, and that file drives the real producer; a +row marked `implementedBy` waits for the branch that ships it. For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index 663b0c31fc..7070d689b3 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -42,6 +42,7 @@ export const DISPATCH_DISCLOSURE_TABLE_PATH = path.join( export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> = { 'daemon.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', 'daemon.scroll-no-progress': 'src/daemon/__tests__/scroll-movement.test.ts', + 'daemon.route.': 'src/daemon/__tests__/request-dispatch-disclosure.test.ts', 'post-action-guard.': 'src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts', 'ios-runner.': 'packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts', diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts new file mode 100644 index 0000000000..3af46a1711 --- /dev/null +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, test, vi } from 'vitest'; + +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) }; +}); + +vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); + +import { AppError } from '@agent-device/kernel/errors'; +import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeAndroidSession, makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonRequest } from '../daemon-request.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import type { SessionState } from '../session-state.ts'; +import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; +import { + createRequestHandler, + gestureRuntimeSpies, + lifecycleDeviceRuntimeGateway, +} from './test-device-runtime-gateway.ts'; + +// contracts/fixtures/dispatch-disclosure.json, daemon.route rows: each drives one request through +// the real request router, with only the bound device operations faked. + +const SESSION = 'dispatch-route'; + +beforeEach(() => { + gestureRuntimeSpies.scrollDirection.mockReset(); + gestureRuntimeSpies.scrollDirection.mockResolvedValue({}); + gestureRuntimeSpies.captureSnapshot.mockReset(); + gestureRuntimeSpies.captureSnapshot.mockResolvedValue({ + backend: 'xctest', + producer: 'apple-runner', + nodes: [], + }); +}); + +async function route( + session: SessionState, + req: Pick & Partial, + androidObservation: AndroidObservationAdapter = clearAndroidObservationFixture, +) { + const sessionStore = makeSessionStore('agent-device-dispatch-route-'); + sessionStore.set(SESSION, session); + const handler = createRequestHandler({ + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + trackDownloadableArtifact: () => 'artifact-id', + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + androidObservation, + }); + const response = await handler({ token: 'test-token', session: SESSION, flags: {}, ...req }); + assert.ok(!response.ok, `expected ${req.command} to fail`); + throw new AppError(response.error.code, response.error.message, response.error.details); +} + +async function scrollWhoseGestureSendFailed(): Promise { + gestureRuntimeSpies.scrollDirection.mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'socket hang up'), + ); + try { + return await route(makeSession(SESSION), { command: 'scroll', positionals: ['down'] }); + } finally { + assert.equal(gestureRuntimeSpies.scrollDirection.mock.calls.length, 1); + } +} + +async function scrollUntilRefusedBeforeFirstGesture(): Promise { + try { + return await route(makeSession(SESSION), { + command: 'scroll', + positionals: ['down'], + flags: { until: 'label="Missing"' }, + }); + } finally { + assert.equal(gestureRuntimeSpies.captureSnapshot.mock.calls.length, 1); + assert.equal(gestureRuntimeSpies.scrollDirection.mock.calls.length, 0); + } +} + +async function androidScrollThenDialogReadRefused(): Promise { + const observation: AndroidObservationAdapter = { + ...clearAndroidObservationFixture, + readBlockingDialog: async (device) => { + if (gestureRuntimeSpies.scrollDirection.mock.calls.length > 0) { + throw new AppError('COMMAND_FAILED', 'adb device offline', { dispatched: 'no' }); + } + return await clearAndroidObservationFixture.readBlockingDialog(device); + }, + }; + const failure = await route( + makeAndroidSession(SESSION, { appBundleId: 'com.example.app' }), + { command: 'scroll', positionals: ['down'] }, + observation, + ).catch((error: unknown) => error); + assert.ok(failure instanceof AppError); + assert.equal(failure.message, 'adb device offline'); + assert.equal(failure.details?.dispatchedSteps, 1); + throw failure; +} + +async function readOnlyGet(): Promise { + return await route(makeSession(SESSION), { + command: 'get', + positionals: ['text', 'label="Missing"'], + }); +} + +const DRIVERS: Record Promise> = { + 'daemon.route.scroll-transport-failure': scrollWhoseGestureSendFailed, + 'daemon.route.scroll-until-before-first-gesture': scrollUntilRefusedBeforeFirstGesture, + 'daemon.route.scroll-then-dialog-read-refused': androidScrollThenDialogReadRefused, + 'daemon.route.read-only-get': readOnlyGet, +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every daemon.route dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 658ede3a80..7d9dc8d5ed 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -24,13 +24,14 @@ import { } from '../../../request-dispatch-disclosure.ts'; import { clearAndroidObservationFixture } from '../../../__tests__/android-observation-fixture.ts'; import { handleInteractionCommands } from '../../index.ts'; +import type { InteractionRouteInput } from '../types.ts'; import { assertAndroidPressStayedInApp } from '../interaction-android-escape.ts'; import { gestureRuntimeBindingsFixture } from './gesture-runtime-bindings.fixtures.ts'; import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; // contracts/fixtures/dispatch-disclosure.json, daemon and post-action guard rows: the daemon rows -// drive a real `press` through the daemon interaction handler with only the device touch mocked; -// the guard row drives the guard itself. +// drive a real `press` through the daemon interaction handler, inside the router's dispatch seam, +// with only the device touch mocked; the guard row drives the guard itself. vi.mock('../../../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -40,6 +41,16 @@ beforeEach(() => { resetGetRuntimeFixture(); }); +/** The interaction route as the request router runs it: inside the request's dispatch seam. */ +async function routeInteraction(params: InteractionRouteInput) { + const dispatchLedger = createRequestDispatchLedger(); + return await discloseRequestDispatch( + params.req, + dispatchLedger, + async () => await handleInteractionCommands({ ...params, dispatchLedger }), + ); +} + type PressScenario = { command?: 'press' | 'get' | 'fill'; positionals: string[]; @@ -63,7 +74,7 @@ async function press({ command = 'press', positionals }: PressScenario): Promise backend: 'xctest', }; sessionStore.set(session.name, session); - const response = await handleInteractionCommands({ + const response = await routeInteraction({ req: { token: 't', session: session.name, command, positionals, flags: {} }, sessionName: session.name, sessionStore, @@ -128,7 +139,7 @@ async function swipeRefusedOnSecondRepetition(): Promise { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure-swipe'); sessionStore.set(session.name, session); - const response = await handleInteractionCommands({ + const response = await routeInteraction({ req: { token: 't', session: session.name, @@ -168,7 +179,7 @@ async function pressThenForegroundReadRefused(): Promise { return { package: 'com.example.app' }; }, }; - const response = await handleInteractionCommands({ + const response = await routeInteraction({ req: { token: 't', session: session.name, command: 'press', positionals: ['50', '40'] }, sessionName: session.name, sessionStore, @@ -273,7 +284,7 @@ test('a plain Error a backend throws reaches the wire with dispatched unknown', throw new Error('socket hang up'); }) as unknown as typeof bindings.bindDevice; await assert.rejects( - handleInteractionCommands({ + routeInteraction({ req: { token: 't', session: session.name, command: 'press', positionals: ['@e1'], flags: {} }, sessionName: session.name, sessionStore, diff --git a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts b/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts deleted file mode 100644 index 78d7884823..0000000000 --- a/src/daemon/interaction/internal/interaction-dispatch-disclosure.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type AppError, asAppError, discloseDispatch } from '@agent-device/kernel/errors'; -import type { DaemonResponse } from '../../daemon-request.ts'; - -/** A failure response built before any dispatch: the requested operation never reached the device. */ -export function refusedBeforeDispatch( - response: Response, -): Response { - if (response.ok) return response; - return { - ...response, - error: { ...response.error, details: { ...response.error.details, dispatched: 'no' } }, - }; -} - -/** A failure thrown before any dispatch: the requested operation never reached the device. */ -export function thrownBeforeDispatch(error: unknown): AppError { - return discloseDispatch(asAppError(error), 'no'); -} diff --git a/src/daemon/interaction/internal/interaction-touch-fill.ts b/src/daemon/interaction/internal/interaction-touch-fill.ts index f30d9551b9..16dd20e515 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -25,7 +25,7 @@ import { import { dispatchRuntimeInteraction } from './interaction-touch-runtime.ts'; import { parseFillTarget } from './interaction-touch-targets.ts'; import { prepareTouchDispatch } from './interaction-touch-prepare.ts'; -import { refusedBeforeDispatch, thrownBeforeDispatch } from './interaction-dispatch-disclosure.ts'; +import { refusedBeforeDispatch, thrownBeforeDispatch } from '../../request-dispatch-disclosure.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import { noActiveSessionError } from '@agent-device/kernel/contracts'; diff --git a/src/daemon/interaction/internal/interaction-touch-prepare.ts b/src/daemon/interaction/internal/interaction-touch-prepare.ts index 55edff9e99..4a27e30c5d 100644 --- a/src/daemon/interaction/internal/interaction-touch-prepare.ts +++ b/src/daemon/interaction/internal/interaction-touch-prepare.ts @@ -7,7 +7,7 @@ import { } from '../../touch-runtime.ts'; import type { InteractionRouteInput } from './types.ts'; import type { DaemonFailureResponse } from '@agent-device/kernel/contracts'; -import { refusedBeforeDispatch } from './interaction-dispatch-disclosure.ts'; +import { refusedBeforeDispatch } from '../../request-dispatch-disclosure.ts'; export type PreparedTouchDispatch = | Readonly<{ ok: false; response: DaemonFailureResponse }> diff --git a/src/daemon/interaction/internal/interaction-touch-press-admission.ts b/src/daemon/interaction/internal/interaction-touch-press-admission.ts index e12b01e403..b57006e5aa 100644 --- a/src/daemon/interaction/internal/interaction-touch-press-admission.ts +++ b/src/daemon/interaction/internal/interaction-touch-press-admission.ts @@ -11,7 +11,7 @@ import { readRefMutationFrame } from '../../ref-frame.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; import type { SessionState } from '../../session-state.ts'; import { refMutationAdmissionResponse } from './interaction-ref-policy.ts'; -import { refusedBeforeDispatch } from './interaction-dispatch-disclosure.ts'; +import { refusedBeforeDispatch } from '../../request-dispatch-disclosure.ts'; import { settleFlagGuardResponse } from './interaction-flags.ts'; import type { CaptureSnapshotForSession, diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index d0f1704c80..ded46e9785 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -8,11 +8,7 @@ import { finalizeTouchInteraction } from './interaction-runtime.ts'; import { refSnapshotFlagGuardResponse } from '../../ref-snapshot-flag-policy.ts'; import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../../selector-runtime.ts'; import { expireRefFrame } from '../../ref-frame.ts'; -import { - createRequestDispatchLedger, - discloseRequestDispatch, - sendRecordedMutation, -} from '../../request-dispatch-disclosure.ts'; +import { sendRecordedMutation } from '../../request-dispatch-disclosure.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { normalizeError } from '@agent-device/kernel/errors'; import { @@ -30,13 +26,8 @@ export async function handleInteractionCommands( params: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession }, ): Promise { const captureProof: RequestCaptureProof = {}; - const dispatchLedger = params.dispatchLedger ?? createRequestDispatchLedger(); - const routed = { ...params, refSnapshotFlagGuardResponse, captureProof, dispatchLedger }; - const response = await discloseRequestDispatch( - params.req, - dispatchLedger, - async () => await dispatchInteractionCommand(routed), - ); + const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; + const response = await dispatchInteractionCommand(routed); return response ? withCaptureDisclosures({ response, consumedTree: captureProof, captureProof }) : response; diff --git a/src/daemon/request-dispatch-disclosure.ts b/src/daemon/request-dispatch-disclosure.ts index 74015af89c..037a6ab926 100644 --- a/src/daemon/request-dispatch-disclosure.ts +++ b/src/daemon/request-dispatch-disclosure.ts @@ -1,4 +1,5 @@ import { + type AppError, asAppError, detailsAfterDispatchedSteps, discloseDispatch, @@ -36,18 +37,18 @@ export async function sendRecordedMutation( * failure is `unknown` with the sent count in `details.dispatchedSteps`; `unknown` fills a failure * no producer classified. A command without a declared effect passes through. */ -export async function discloseRequestDispatch( +export async function discloseRequestDispatch( req: DaemonRequest, ledger: RequestDispatchLedger, - dispatch: () => Promise, -): Promise { + dispatch: () => Promise, +): Promise { const effect = resolveCommandRecordingEffect(req); try { const response = await dispatch(); if (!response || response.ok || effect === undefined) return response; const details = disclosedDetails(effect, response.error.details, ledger); if (details === response.error.details) return response; - return { ok: false, error: { ...response.error, details } }; + return { ...response, error: { ...response.error, details } }; } catch (error) { if (effect === undefined) throw error; const failure = asAppError(error); @@ -69,3 +70,19 @@ function disclosedDetails( if (afterSteps?.dispatched !== undefined) return afterSteps; return { ...afterSteps, dispatched: 'unknown' }; } + +/** A failure response built before any dispatch: the requested operation never reached the device. */ +export function refusedBeforeDispatch( + response: Response, +): Response { + if (response.ok) return response; + return { + ...response, + error: { ...response.error, details: { ...response.error.details, dispatched: 'no' } }, + }; +} + +/** A failure thrown before any dispatch: the requested operation never reached the device. */ +export function thrownBeforeDispatch(error: unknown): AppError { + return discloseDispatch(asAppError(error), 'no'); +} diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 8884506f2a..2a75bb35b8 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -24,6 +24,7 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runt import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; +import type { RequestDispatchLedger } from './request-dispatch-disclosure.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -57,6 +58,7 @@ type RequestHandlerChainParams = { screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; hostDiagnostics?: HostDiagnostics; requestScope: PlatformRequestScope; + dispatchLedger?: RequestDispatchLedger; retainDeviceExecutionLock(deviceId: string): Promise; throwIfCanceled(): void; contextFromFlags: ( @@ -289,6 +291,7 @@ async function runInteractionHandler( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, androidObservation: params.androidObservation, + dispatchLedger: params.dispatchLedger, }), ); } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 3a96f67aab..40c67b14cd 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -79,6 +79,13 @@ import { } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; +import { + createRequestDispatchLedger, + discloseRequestDispatch, + refusedBeforeDispatch, + sendRecordedMutation, + type RequestDispatchLedger, +} from './request-dispatch-disclosure.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import { restrictDeviceInventoryToDaemonPolicy } from './daemon-policy.ts'; @@ -303,6 +310,27 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { allowReplayActions: boolean; }): Promise { const { lockedScope, providerScope, allowReplayActions } = params; + const dispatchLedger = createRequestDispatchLedger(); + return await discloseRequestDispatch( + lockedScope.req, + dispatchLedger, + async () => + await routeLockedRequest({ + lockedScope, + providerScope, + allowReplayActions, + dispatchLedger, + }), + ); + } + + async function routeLockedRequest(params: { + lockedScope: LockedRequestScope; + providerScope: RequestPlatformProviderScope; + allowReplayActions: boolean; + dispatchLedger: RequestDispatchLedger; + }): Promise { + const { lockedScope, providerScope, allowReplayActions, dispatchLedger } = params; const requestScope = createPlatformRequestScope(lockedScope.req); const handlerResponse = await runRequestHandlerChain({ req: lockedScope.req, @@ -332,6 +360,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { screenRecordingAdmissionLedger, hostDiagnostics, requestScope, + dispatchLedger, retainDeviceExecutionLock: lockedScope.retainDeviceExecutionLock, throwIfCanceled: lockedScope.throwIfCanceled, contextFromFlags: lockedScope.handlerContextFromFlags, @@ -343,6 +372,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { logPath: lockedScope.logPath, sessionStore, androidObservation, + dispatchLedger, }); } @@ -468,8 +498,9 @@ async function dispatchGenericForLockedScope(params: { logPath: string; sessionStore: SessionStore; androidObservation: AndroidObservationAdapter; + dispatchLedger: RequestDispatchLedger; }): Promise { - const { lockedScope, logPath, sessionStore, androidObservation } = params; + const { lockedScope, logPath, sessionStore, androidObservation, dispatchLedger } = params; const session = sessionStore.get(lockedScope.sessionName); if (!session) { return noActiveSessionError(); @@ -488,7 +519,8 @@ async function dispatchGenericForLockedScope(params: { inspectFacts: lockedScope.inspectFacts, bindDevice: lockedScope.bindDevice, }); - if (!runtimeExecution.ok) return runtimeExecution.response; + if (!runtimeExecution.ok) return refusedBeforeDispatch(runtimeExecution.response); + const execute = runtimeExecution.execute; const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ @@ -498,7 +530,8 @@ async function dispatchGenericForLockedScope(params: { logPath, sessionStore, contextFromFlags: lockedScope.contextFromFlags, - executePlatformCommand: runtimeExecution.execute, + executePlatformCommand: async (execution) => + await sendRecordedMutation(dispatchLedger, async () => await execute(execution)), androidObservation, ...(runtimeExecution.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), }); diff --git a/src/daemon/scroll-until.ts b/src/daemon/scroll-until.ts index dd956d15ec..cf4b6f4376 100644 --- a/src/daemon/scroll-until.ts +++ b/src/daemon/scroll-until.ts @@ -1,6 +1,10 @@ import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; -import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; +import { + AppError, + discloseDispatch, + discloseDispatchAfterSteps, +} from '@agent-device/kernel/errors'; import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; @@ -71,6 +75,7 @@ export async function runScrollUntilVisible(params: { let result: TResult | undefined; const recentSignatures: string[] = []; + let scrolling = false; try { while (true) { const decision = await decideUntilPass({ @@ -86,10 +91,15 @@ export async function runScrollUntilVisible(params: { if (decision.visible) { return { passes, ...(result === undefined ? {} : { result }) }; } + scrolling = true; result = await scroll(); + scrolling = false; passes += 1; } } catch (error) { + if (passes === 0 && !scrolling && error instanceof AppError) { + throw discloseDispatch(error, 'no'); + } throw discloseDispatchAfterSteps(error, passes); } } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 18d2a46f74..383122e636 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -494,7 +494,7 @@ When an interaction fails, read `error.details.dispatched` before you retry: - `no`: the action never reached the device. Retry it as it is. - `unknown`: the action may have landed. Take a snapshot before you retry; a blind retry can tap, type, or navigate twice. -An interaction on a read-only command such as `get` reports `no`: it changes nothing, so a retry is safe. +A read-only command such as `get`, `snapshot`, or `wait` reports `no`: it changes nothing, so a retry is safe. A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. From cd6d773a75ab32d61ae6590eab623ce120500dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 18:06:04 +0200 Subject: [PATCH 21/42] fix(apple-runner): pre-send validation and restart replay disclose their verdict Sequence validation ran inside the transport catch of runApplePressSeries, so an invalid step (a non-finite point) reached the wire undisclosed and the daemon filled `unknown`. Every validation refusal now comes from one constructor, `invalidSequence`, which carries `reason: runner_sequence_invalid` and `dispatched: no`; the series builds and validates every chunk before it sends the first, so a refusal can never follow a sent chunk. A restart replay after an unwritten first attempt returned the replay failure as is, so a replay failure without producer evidence escaped undisclosed. It now keeps an existing verdict, is `no` for a pre-send refusal (isRunnerPreSendRefusal), and is `unknown` otherwise. Rows: ios-runner.series.invalid-step-before-send, ios-runner.transport.replay-failed-after-unwritten-first-attempt. Mutations: `invalidSequence` disclosing `unknown` fails the invalid-step row; passing the replay failure through fails the replay row. --- contracts/fixtures/dispatch-disclosure.json | 12 +++++++ .../runner-dispatch-disclosure.test.ts | 21 ++++++++++++ ...nner-lifecycle-dispatch-disclosure.test.ts | 13 ++++++++ .../src/runner/runner-lifecycle.ts | 6 ++-- .../src/runner/runner-sequence.ts | 32 +++++++++++++------ 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 7c043ef626..6c7582cc3c 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -205,6 +205,12 @@ "trigger": "press --count 25 splits into two sequence chunks; the first ran and the second was refused with RUNNER_BUSY; the series reports unknown with details.dispatchedSteps 1", "dispatched": "unknown" }, + { + "id": "ios-runner.series.invalid-step-before-send", + "producer": "ios-runner", + "trigger": "press --count 2 at a non-finite point: sequence validation refuses the steps before any sequence request is sent (reason runner_sequence_invalid)", + "dispatched": "no" + }, { "id": "ios-runner.pre-send.session-start-failed", "producer": "ios-runner", @@ -229,6 +235,12 @@ "trigger": "the readiness preflight gave up before the command was written, again after the restart", "dispatched": "no" }, + { + "id": "ios-runner.transport.replay-failed-after-unwritten-first-attempt", + "producer": "ios-runner", + "trigger": "the first attempt was refused before the write (readiness preflight), the restart replayed the command, and the replay failed with no producer verdict; the replay may have landed", + "dispatched": "unknown" + }, { "id": "ios-runner.status.failed", "producer": "ios-runner", diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 7281459cbb..8961a13cbe 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -123,6 +123,26 @@ async function pressSeriesRefusedOnSecondChunk(): Promise { } } +/** `press --count 2` at a non-finite point: validation refuses before any sequence is sent. */ +async function pressSeriesWithInvalidStep(): Promise { + const runCommand = vi.fn(async () => ({})); + try { + return await runApplePressSeries( + IOS_SIMULATOR, + { x: Number.NaN, y: 20 }, + { button: 'primary', count: 2, intervalMs: 0, holdMs: 0, jitterPx: 0, doubleTap: false }, + undefined, + runCommand, + ); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'runner_sequence_invalid'); + throw error; + } finally { + assert.equal(runCommand.mock.calls.length, 0); + } +} + function statusReply(data: Record): FakeRunnerResponse[] { return [{ kind: 'ok', data }]; } @@ -147,6 +167,7 @@ const DRIVERS: Record Promise> = { 'ios-runner.reply.MAIN_THREAD_TIMEOUT': () => replyFailure('MAIN_THREAD_TIMEOUT'), 'ios-runner.reply.unlisted-code': () => replyFailure('XCTEST_RECORDED_FAILURE'), 'ios-runner.series.later-chunk-refused': pressSeriesRefusedOnSecondChunk, + 'ios-runner.series.invalid-step-before-send': pressSeriesWithInvalidStep, 'ios-runner.status.failed': () => lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorMessage: 'tap failed' })), 'ios-runner.status.failed-RUNNER_BUSY': () => diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index 900bf4dce6..6380c6295e 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -138,6 +138,19 @@ const DRIVERS: Record Promise> = { .mockRejectedValueOnce(readinessPreflightFailure()); return await tap(); }, + 'ios-runner.transport.replay-failed-after-unwritten-first-attempt': async () => { + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101 })); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(readinessPreflightFailure()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner replay failed')); + try { + return await tap(); + } finally { + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + } + }, }; const ROWS = dispatchDisclosureRowsOwnedBy( diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index bbc7774c23..86cbbae03f 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -505,7 +505,8 @@ function markRunnerRestartError( /** * A restart that never replayed says what the first attempt did; a replay after a first attempt - * that may have written the command cannot claim `no` for the two sends together. + * that may have written the command cannot claim `no` for the two sends together. After an unwritten + * first attempt the replay's own verdict stands; without one, only a pre-send refusal is `no`. */ function discloseRestartDispatch( error: AppError, @@ -513,7 +514,8 @@ function discloseRestartDispatch( restartedSession: RunnerSession | undefined, ): AppError { if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); - return firstAttemptUnwritten ? error : discloseDispatch(error, 'unknown'); + if (!firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); + return discloseUnclassifiedDispatch(error, isRunnerPreSendRefusal(error) ? 'no' : 'unknown'); } async function runPrepareHealthCheck( diff --git a/packages/platform-apple/src/runner/runner-sequence.ts b/packages/platform-apple/src/runner/runner-sequence.ts index 2a7c503437..e38f971c4e 100644 --- a/packages/platform-apple/src/runner/runner-sequence.ts +++ b/packages/platform-apple/src/runner/runner-sequence.ts @@ -1,7 +1,12 @@ import type { PressPointOptions } from '@agent-device/contracts/interactor-types'; import { pressJitter } from '@agent-device/contracts/touch-runtime'; import { runnerSynthesizesTap, type DeviceInfo } from '@agent-device/kernel/device'; -import { AppError, discloseDispatchAfterSteps, toAppErrorCode } from '@agent-device/kernel/errors'; +import { + AppError, + discloseDispatch, + discloseDispatchAfterSteps, + toAppErrorCode, +} from '@agent-device/kernel/errors'; import type { RunnerCommand, RunnerSequenceStep } from './runner-contract.ts'; export const SEQUENCEABLE_RUNNER_STEP_KINDS = ['tap', 'doubleTap', 'longPress'] as const; @@ -55,8 +60,16 @@ function isSequenceableKind(kind: unknown): kind is SequenceableRunnerStepKind { ); } +/** A sequence the daemon refuses before sending it: nothing reached the runner. */ +function invalidSequence(message: string, details: Record): AppError { + return discloseDispatch( + new AppError('INVALID_ARGS', message, { ...details, reason: 'runner_sequence_invalid' }), + 'no', + ); +} + function invalidStep(index: number, kind: unknown, message: string): AppError { - return new AppError('INVALID_ARGS', message, { + return invalidSequence(message, { stepIndex: index, kind: typeof kind === 'string' ? kind : undefined, }); @@ -71,13 +84,12 @@ function invalidStep(index: number, kind: unknown, message: string): AppError { */ export function validateRunnerSequenceSteps(steps: RunnerSequenceStep[]): void { if (!Array.isArray(steps) || steps.length === 0) { - throw new AppError('INVALID_ARGS', 'sequence requires at least one step', { + throw invalidSequence('sequence requires at least one step', { stepCount: Array.isArray(steps) ? steps.length : 0, }); } if (steps.length > MAX_RUNNER_SEQUENCE_STEPS) { - throw new AppError( - 'INVALID_ARGS', + throw invalidSequence( `sequence accepts at most ${MAX_RUNNER_SEQUENCE_STEPS} steps, received ${steps.length}`, { stepCount: steps.length, maxSteps: MAX_RUNNER_SEQUENCE_STEPS }, ); @@ -147,20 +159,20 @@ export async function runApplePressSeries( appBundleId: string | undefined, runCommand: (command: RunnerCommand) => Promise>, ): Promise> { - const chunks = chunkRunnerSequenceStepsByBudget( + const commands = chunkRunnerSequenceStepsByBudget( buildPressSteps(device, point, options), MAX_RUNNER_SEQUENCE_STEPS, - ); + ).map((chunk) => buildRunnerSequenceCommand(chunk, appBundleId)); let first: Record | undefined; let last: Record | undefined; let completedSteps = 0; const sequenceResults: unknown[] = []; let stepOffset = 0; let dispatchedChunks = 0; - for (const chunk of chunks) { + for (const command of commands) { let result: Record; try { - result = await runCommand(buildRunnerSequenceCommand(chunk, appBundleId)); + result = await runCommand(command); } catch (error) { throw discloseDispatchAfterSteps(error, dispatchedChunks); } @@ -179,7 +191,7 @@ export async function runApplePressSeries( } completedSteps += parsed.completedSteps; sequenceResults.push(...parsed.results); - stepOffset += chunk.length; + stepOffset += command.steps?.length ?? 0; } // Preserve the first response's acquisition frame and the last response's gesture completion. return { From b754cc13e078cf1093c496a1a7667e1c403a8116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 18:07:13 +0200 Subject: [PATCH 22/42] test(daemon): type the route driver's dialog-read stub as the adapter declares it --- src/daemon/__tests__/request-dispatch-disclosure.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts index 3af46a1711..5663976adf 100644 --- a/src/daemon/__tests__/request-dispatch-disclosure.test.ts +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -97,11 +97,11 @@ async function scrollUntilRefusedBeforeFirstGesture(): Promise { async function androidScrollThenDialogReadRefused(): Promise { const observation: AndroidObservationAdapter = { ...clearAndroidObservationFixture, - readBlockingDialog: async (device) => { + readBlockingDialog: async () => { if (gestureRuntimeSpies.scrollDirection.mock.calls.length > 0) { throw new AppError('COMMAND_FAILED', 'adb device offline', { dispatched: 'no' }); } - return await clearAndroidObservationFixture.readBlockingDialog(device); + return { status: 'clear' }; }, }; const failure = await route( From 9e0c551cb9600f1daed96559a9dfad9358031abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:10:23 +0200 Subject: [PATCH 23/42] refactor(daemon): the request scope owns the dispatch ledger; every bound mutation records itself The ledger was threaded route by route (InteractionRouteInput, InteractionRuntimeInput, RequestHandlerChainParams, executePlatformCommand), so `find type`, session, snapshot/alert, react-native, and record/trace routes never recorded their sends, and the Maestro port kept a second ledger plus MAESTRO_OPERATION_MUTATES, which restated the registry. The ledger now lives on the request execution scope and is handed to createRequestRuntimeBindings, the one place bindDevice and bindExactDevice narrow a binding. Every narrowed projection wraps each operation that RUNTIME_OPERATION_EFFECTS (src/daemon/runtime-operation-effects.ts) declares `mutates`, and records it when its send returns. The table is a Record over the runtime operation keys, so an operation with no declared effect fails typecheck and the completeness test. A replay action runs on a ledger of its own (req.internal.dispatchLedger) and moves its sends into the parent's. The router discloses around the scope's ledger: once it holds a sent mutation, a failure is `unknown` with the count added to details.dispatchedSteps, for any declared effect or none; a read with an empty ledger stays `no`. Deleted: the route dispatchLedger fields, sendRecordedMutation, MaestroMutationLedger, MAESTRO_OPERATION_MUTATES, and the per-command Maestro wrapper. The three maestro-port rows become one router row. Rows: daemon.route.find-type-refused-after-focus, daemon.route.session-close-then-finalize-refused, daemon.route.maestro-deferred-settle-capture-refused. Mutations: dropping recordBoundMutations from bindDevice fails the find-type, close, Maestro, and scroll-then-dialog-read rows; dropping the nested ledger from the replay action invoker fails the Maestro row; deleting one entry from RUNTIME_OPERATION_EFFECTS fails typecheck and the completeness test. --- contracts/fixtures/dispatch-disclosure.json | 36 ++--- .../src/dispatch-disclosure.fixtures.ts | 2 - .../__tests__/daemon-runtime-port.test.ts | 136 ------------------ .../daemon-runtime-port-support.ts | 11 +- .../src/daemon-port/daemon-runtime-port.ts | 33 +---- .../daemon-runtime-public-operation.ts | 22 --- .../gesture-admission-parity.test.ts | 2 + .../request-dispatch-disclosure.test.ts | 124 +++++++++++++++- .../__tests__/request-dispatch-ledger.test.ts | 82 +++++++++++ .../__tests__/request-handler-chain.test.ts | 2 + .../__tests__/request-runtime-binding.test.ts | 29 +++- .../runtime-operation-effects.test.ts | 16 +++ src/daemon/daemon-request.ts | 6 + .../interaction-dispatch-disclosure.test.ts | 17 ++- .../internal/interaction-runtime.ts | 21 +-- .../internal/interaction-touch-direct-ios.ts | 7 +- .../interaction/internal/interaction.ts | 11 +- src/daemon/interaction/internal/types.ts | 4 - src/daemon/request-dispatch-disclosure.ts | 50 +++---- src/daemon/request-dispatch-ledger.ts | 88 ++++++++++++ src/daemon/request-execution-scope.ts | 10 ++ src/daemon/request-handler-chain.ts | 3 - src/daemon/request-router.ts | 38 ++--- src/daemon/request-runtime-binding.ts | 15 +- src/daemon/runtime-operation-effects.ts | 97 +++++++++++++ src/platform-runtime-gateway.test.ts | 2 + .../managed-request-admission.fixtures.ts | 2 + ...ale-provider-runtime-admission.fixtures.ts | 2 + 28 files changed, 545 insertions(+), 323 deletions(-) create mode 100644 src/daemon/__tests__/request-dispatch-ledger.test.ts create mode 100644 src/daemon/__tests__/runtime-operation-effects.test.ts create mode 100644 src/daemon/request-dispatch-ledger.ts create mode 100644 src/daemon/runtime-operation-effects.ts diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 6c7582cc3c..78fcfdbb83 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -71,6 +71,24 @@ "trigger": "get through the request router: the router, not only the interaction route, discloses no for a command the registry declares observes-app", "dispatched": "no" }, + { + "id": "daemon.route.find-type-refused-after-focus", + "producer": "daemon", + "trigger": "find type: the focus tap returned, then the typeText send was refused before send (no); the request reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, + { + "id": "daemon.route.session-close-then-finalize-refused", + "producer": "daemon", + "trigger": "close (session route): the app-close send returned, then the lifecycle finalize was refused before send (no); the cleanup failure reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, + { + "id": "daemon.route.maestro-deferred-settle-capture-refused", + "producer": "daemon", + "trigger": "a Maestro replay (session route): back (stability deferred) returned, then the deferred settle capture the next command takes was refused with RUNNER_BUSY (no); the replay reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, { "id": "maestro-direct.fallback.pre-send-refusal", "producer": "daemon", @@ -85,24 +103,6 @@ "dispatched": "unknown", "fallsBack": false }, - { - "id": "maestro-port.scroll-until.capture-after-scroll-refused", - "producer": "maestro-port", - "trigger": "scrollUntilVisible sent its scroll and the settle capture after it was refused with RUNNER_BUSY (no); the Maestro command reports unknown with details.dispatchedSteps 1", - "dispatched": "unknown" - }, - { - "id": "maestro-port.input-text.settle-capture-refused", - "producer": "maestro-port", - "trigger": "inputText typed its text and the settle capture after it was refused with RUNNER_BUSY (no); the Maestro command reports unknown with details.dispatchedSteps 1", - "dispatched": "unknown" - }, - { - "id": "maestro-port.scroll-until.first-capture-refused", - "producer": "maestro-port", - "trigger": "scrollUntilVisible's first capture, before any scroll, was refused with RUNNER_BUSY; nothing was sent, so the producer's no stands", - "dispatched": "no" - }, { "id": "post-action-guard.android-press-left-app", "producer": "post-action-guard", diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index 7070d689b3..ab6ae64627 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -10,7 +10,6 @@ export const DISPATCH_DISCLOSURE_PRODUCERS = [ 'android-helper', 'webdriver', 'post-action-guard', - 'maestro-port', ] as const; export type DispatchDisclosureProducer = (typeof DISPATCH_DISCLOSURE_PRODUCERS)[number]; @@ -56,7 +55,6 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'packages/platform-android/src/__tests__/touch-helper-session.test.ts', 'maestro-direct.': 'src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts', - 'maestro-port.': 'packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts', }; /** The per-row driving loop every driver file carries. */ diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts index d0aadbfcdc..77af925b8b 100644 --- a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts @@ -20,12 +20,6 @@ import { } from './daemon-runtime-port-fixtures.ts'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; import { formatRole } from '@agent-device/kernel/snapshot'; -import { AppError } from '@agent-device/kernel/errors'; -import { - assertDispatchDisclosureDriversMatchRows, - DISPATCH_DISCLOSURE_TABLE_PATH, - dispatchDisclosureRowsOwnedBy, -} from '@agent-device/contracts/dispatch-disclosure-fixtures'; test('registers Maestro inputText as sensitive before nested platform work', async () => { const root = mkdtempForTestSync('agent-device-maestro-input-diagnostics-'); @@ -798,133 +792,3 @@ test('timed-out waitForAnimationToEnd retains the pending hierarchy settle', asy 'click', ]); }); - -// contracts/fixtures/dispatch-disclosure.json, maestro-port rows: each drives one Maestro command -// through the real port with only the daemon invoke faked. - -const RUNNER_BUSY_SNAPSHOT_REFUSAL = { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'runner busy', - details: { - reason: 'runner_busy', - runnerErrorCode: 'RUNNER_BUSY', - retriable: true, - dispatched: 'no', - }, - }, -} as const; - -const DISCOVER_OFFSCREEN_SNAPSHOT = { - ok: true, - data: { - createdAt: 0, - nodes: [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } }, - { - index: 1, - parentIndex: 0, - type: 'Text', - label: 'Discover', - rect: { x: 20, y: 900, width: 120, height: 48 }, - }, - ], - }, -} as const; - -async function executeWithInvoke( - command: Parameters['execute']>[0]['command'], - invoke: MaestroDaemonOperationInvoke, -): Promise { - const port = createDaemonMaestroRuntimePort({ - ...makeRuntimeEnvelope({ flags: { platform: 'ios', replayBackend: 'maestro' } }), - invoke, - dependencies: makeDependencies(), - platform: 'ios', - }); - return await port.execute({ command, generation: 0, env: {}, invalidateObservation() {} }); -} - -const SCROLL_UNTIL_DISCOVER = { - kind: 'scrollUntilVisible', - source: { line: 2 }, - element: { text: 'Discover' }, - direction: 'up', - timeout: 2_000, -} as const; - -async function scrollUntilWithCaptureRefusedAfterScroll(): Promise { - const commands: string[] = []; - try { - return await executeWithInvoke(SCROLL_UNTIL_DISCOVER, async (request) => { - commands.push(request.command); - if (request.command !== 'snapshot') return { ok: true, data: {} }; - return commands.includes('scroll') - ? RUNNER_BUSY_SNAPSHOT_REFUSAL - : DISCOVER_OFFSCREEN_SNAPSHOT; - }); - } finally { - expect(commands.filter((command) => command === 'scroll')).toHaveLength(1); - } -} - -async function scrollUntilWithFirstCaptureRefused(): Promise { - const commands: string[] = []; - try { - return await executeWithInvoke(SCROLL_UNTIL_DISCOVER, async (request) => { - commands.push(request.command); - return request.command === 'snapshot' ? RUNNER_BUSY_SNAPSHOT_REFUSAL : { ok: true, data: {} }; - }); - } finally { - expect(commands).not.toContain('scroll'); - } -} - -async function inputTextWithSettleCaptureRefused(): Promise { - const commands: string[] = []; - try { - return await executeWithInvoke( - { kind: 'inputText', source: { line: 2 }, text: 'hello' }, - async (request) => { - commands.push(request.command); - return request.command === 'snapshot' - ? RUNNER_BUSY_SNAPSHOT_REFUSAL - : { ok: true, data: {} }; - }, - ); - } finally { - expect(commands[0]).toBe('type'); - } -} - -const DRIVERS: Record Promise> = { - 'maestro-port.scroll-until.capture-after-scroll-refused': - scrollUntilWithCaptureRefusedAfterScroll, - 'maestro-port.input-text.settle-capture-refused': inputTextWithSettleCaptureRefused, - 'maestro-port.scroll-until.first-capture-refused': scrollUntilWithFirstCaptureRefused, -}; - -const ROWS = dispatchDisclosureRowsOwnedBy( - import.meta.url, - fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), -); - -test('every maestro-port dispatch-disclosure row has exactly one driver', () => { - assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); -}); - -for (const row of ROWS) { - test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { - const drive = DRIVERS[row.id]; - expect(drive, `no driver for ${row.id}`).toBeDefined(); - const failure = await drive!().then( - () => undefined, - (error: unknown) => error, - ); - expect(failure).toBeInstanceOf(AppError); - const details = (failure as AppError).details; - expect(details?.dispatched).toBe(row.dispatched); - expect(details?.dispatchedSteps).toBe(row.dispatched === 'unknown' ? 1 : undefined); - }); -} diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts b/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts index c1ea8b5517..2d900593fd 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port-support.ts @@ -13,7 +13,6 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import type { DaemonMaestroRuntimeDependencies } from './daemon-runtime-port-observation.ts'; import { stripUndefined } from '@agent-device/kernel/record'; import { - isMaestroMutationOperation, projectMaestroPublicOperation, type MaestroDaemonOperationRequest, type MaestroPublicOperation, @@ -37,9 +36,6 @@ export type CreateDaemonMaestroRuntimeOperationsOptions = { readonly platform: Extract; }; -/** Mutating operations whose daemon request returned ok, counted across one port. */ -export type MaestroMutationLedger = { sent: number }; - type MaestroPublicOperationResult = Operation extends { kind: 'gestureViewport'; } @@ -47,9 +43,7 @@ type MaestroPublicOperationResult = Op : DaemonResponseData | undefined; export async function invokeMaestroPublicOperation( - options: CreateDaemonMaestroRuntimeOperationsOptions & { - readonly mutationLedger?: MaestroMutationLedger; - }, + options: CreateDaemonMaestroRuntimeOperationsOptions, operation: Operation, ): Promise> { const projected = projectMaestroPublicOperation(operation); @@ -57,9 +51,6 @@ export async function invokeMaestroPublicOperation MaestroRuntimeMetrics; recordSettle: (stable: StableMaestroSnapshot) => void; } { - const mutationLedger: MaestroMutationLedger = { sent: 0 }; - const options = { ...envelope, mutationLedger }; const snapshots = createDaemonMaestroSnapshotSource(options); const metrics: Omit = { screenshotCaptures: 0, @@ -358,37 +355,13 @@ function createDaemonMaestroRuntimeParts(envelope: CreateDaemonMaestroRuntimeOpe }), }; return { - operations: discloseDispatchAfterMutations(operations, mutationLedger), + operations, snapshots, readMetrics: () => ({ ...snapshots.readMetrics(), ...metrics }), recordSettle, }; } -/** - * Each operation is one Maestro command: once a mutation it sent returned, a later failure of that - * command (a settle capture, a retry, a verification read) is `unknown`, never a producer's `no`. - */ -function discloseDispatchAfterMutations( - operations: MaestroRuntimeOperations, - ledger: MaestroMutationLedger, -): MaestroRuntimeOperations { - const disclosed: Record = { ...operations }; - for (const [name, operation] of Object.entries(operations)) { - if (typeof operation !== 'function') continue; - const run = operation as (...args: unknown[]) => Promise; - disclosed[name] = async (...args: unknown[]) => { - const sentBefore = ledger.sent; - try { - return await run(...args); - } catch (error) { - throw discloseDispatchAfterSteps(error, ledger.sent - sentBefore); - } - }; - } - return disclosed as MaestroRuntimeOperations; -} - export function createDaemonMaestroRuntimePort( options: CreateDaemonMaestroRuntimeOperationsOptions, ): MaestroRuntimePort { diff --git a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts index 724f791575..9016723661 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts @@ -76,28 +76,6 @@ export type MaestroDaemonOperationRequest = { dispatch?: MaestroDaemonDispatchOptions; }; -const MAESTRO_OPERATION_MUTATES: Readonly> = { - launchApp: true, - stopApp: true, - clearState: true, - settingsPermission: true, - openLink: true, - typeText: true, - clickSelector: true, - clickPoint: true, - swipe: true, - scroll: true, - pressKey: true, - screenshot: false, - snapshot: false, - gestureViewport: false, -}; - -/** Whether the daemon request this operation projects to changes the device. */ -export function isMaestroMutationOperation(operation: MaestroPublicOperation): boolean { - return MAESTRO_OPERATION_MUTATES[operation.kind]; -} - export function projectMaestroPublicOperation( operation: MaestroPublicOperation, ): MaestroDaemonOperationRequest { diff --git a/src/daemon/__tests__/gesture-admission-parity.test.ts b/src/daemon/__tests__/gesture-admission-parity.test.ts index fff9c0cadf..7a89504542 100644 --- a/src/daemon/__tests__/gesture-admission-parity.test.ts +++ b/src/daemon/__tests__/gesture-admission-parity.test.ts @@ -10,6 +10,7 @@ import { import type { DeviceInfo } from '@agent-device/kernel/device'; import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; import { createRequestRuntimeBindings } from '../request-runtime-binding.ts'; +import { createRequestDispatchLedger } from '../request-dispatch-ledger.ts'; import { resolveBoundGestureRuntime } from '../gesture-runtime.ts'; /** @@ -58,6 +59,7 @@ const device = (fields: Partial): DeviceInfo => ({ /** The production binding seam, so admission runs against the real inspect-then-bind path. */ async function admit(input: GestureCommandInput, target: DeviceInfo) { const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway, scope: { signal: new AbortController().signal, diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts index 5663976adf..7b9466a002 100644 --- a/src/daemon/__tests__/request-dispatch-disclosure.test.ts +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -13,13 +13,19 @@ vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () import { AppError } from '@agent-device/kernel/errors'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; +import type { DeviceRuntimeGateway, RuntimeFacts } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { assertDispatchDisclosureDriversMatchRows, DISPATCH_DISCLOSURE_TABLE_PATH, dispatchDisclosureRowsOwnedBy, } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; -import { makeAndroidSession, makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { + makeAndroidSession, + makeIosAppSession, + makeSession, +} from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import type { DaemonRequest } from '../daemon-request.ts'; @@ -37,7 +43,65 @@ import { const SESSION = 'dispatch-route'; +const RUNNER_BUSY = () => + new AppError('COMMAND_FAILED', 'runner busy', { reason: 'runner_busy', dispatched: 'no' }); + +const CONTINUE_BUTTON = { + index: 0, + type: 'XCUIElementTypeButton', + label: 'Continue', + rect: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + hittable: true, +}; + +const EMAIL_FIELD = { + index: 1, + type: 'XCUIElementTypeTextField', + label: 'Email', + rect: { x: 10, y: 80, width: 200, height: 40 }, + enabled: true, + hittable: true, +}; + +/** The operations the lifecycle gateway lacks that these rows drive, one spy each. */ +const routeOperationSpies = { + focusPoint: vi.fn(async () => undefined), + typeText: vi.fn(async () => undefined), + back: vi.fn(async () => undefined), + finalizeApplicationClose: vi.fn(async () => undefined as void), +}; + +const available = Object.freeze({ available: true as const }); + +function withRouteOperationFacts( + facts: RuntimeFacts, +): RuntimeFacts { + const operations = { ...facts.operations }; + for (const name of Object.keys(routeOperationSpies) as (keyof typeof routeOperationSpies)[]) { + operations[name] = available; + } + return { ...facts, operations }; +} + +const routeDeviceRuntimeGateway: DeviceRuntimeGateway = { + inspectFacts: async (device) => + withRouteOperationFacts(await lifecycleDeviceRuntimeGateway.inspectFacts(device)), + bind: async (request) => { + const binding = await lifecycleDeviceRuntimeGateway.bind(request); + return { + ...binding, + facts: withRouteOperationFacts(binding.facts), + operations: { ...binding.operations, ...routeOperationSpies }, + }; + }, + shutdown: async () => {}, +}; + beforeEach(() => { + for (const spy of Object.values(routeOperationSpies)) spy.mockClear(); + routeOperationSpies.typeText.mockImplementation(async () => undefined); + routeOperationSpies.finalizeApplicationClose.mockImplementation(async () => undefined); gestureRuntimeSpies.scrollDirection.mockReset(); gestureRuntimeSpies.scrollDirection.mockResolvedValue({}); gestureRuntimeSpies.captureSnapshot.mockReset(); @@ -62,7 +126,7 @@ async function route( leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', - deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + deviceRuntimeGateway: routeDeviceRuntimeGateway, androidObservation, }); const response = await handler({ token: 'test-token', session: SESSION, flags: {}, ...req }); @@ -122,7 +186,63 @@ async function readOnlyGet(): Promise { }); } +function captureNodes(nodes: readonly unknown[]): void { + gestureRuntimeSpies.captureSnapshot.mockResolvedValue({ + backend: 'xctest', + producer: 'apple-runner', + nodes, + } as never); +} + +async function findTypeRefusedAfterFocus(): Promise { + captureNodes([CONTINUE_BUTTON, EMAIL_FIELD]); + routeOperationSpies.typeText.mockRejectedValueOnce(RUNNER_BUSY()); + const failure = await route(makeIosAppSession(SESSION), { + command: 'find', + positionals: ['Email', 'type', 'hello'], + }).catch((error: unknown) => error); + assert.equal(routeOperationSpies.focusPoint.mock.calls.length, 1); + assert.equal(routeOperationSpies.typeText.mock.calls.length, 1); + assert.ok(failure instanceof AppError); + assert.equal(failure.details?.dispatchedSteps, 1); + throw failure; +} + +async function closeThenFinalizeRefused(): Promise { + routeOperationSpies.finalizeApplicationClose.mockRejectedValueOnce(RUNNER_BUSY()); + const failure = await route(makeIosAppSession(SESSION), { + command: 'close', + positionals: ['com.example.app'], + }).catch((error: unknown) => error); + assert.equal(routeOperationSpies.finalizeApplicationClose.mock.calls.length, 1); + assert.ok(failure instanceof AppError); + assert.equal(failure.details?.dispatchedSteps, 1); + throw failure; +} + +async function maestroDeferredSettleCaptureRefused(): Promise { + captureNodes([CONTINUE_BUTTON]); + gestureRuntimeSpies.captureSnapshot.mockImplementation(async () => { + if (routeOperationSpies.back.mock.calls.length > 0) throw RUNNER_BUSY(); + return { backend: 'xctest', producer: 'apple-runner', nodes: [CONTINUE_BUTTON] } as never; + }); + const flowPath = path.join(mkdtempForTestSync('dispatch-route-maestro'), 'flow.yaml'); + fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n- assertVisible: "Continue"\n'); + const failure = await route(makeIosAppSession(SESSION), { + command: 'replay', + positionals: [flowPath], + flags: { replayBackend: 'maestro', platform: 'ios' }, + }).catch((error: unknown) => error); + assert.equal(routeOperationSpies.back.mock.calls.length, 1); + assert.ok(failure instanceof AppError); + assert.equal(failure.details?.dispatchedSteps, 1); + throw failure; +} + const DRIVERS: Record Promise> = { + 'daemon.route.find-type-refused-after-focus': findTypeRefusedAfterFocus, + 'daemon.route.session-close-then-finalize-refused': closeThenFinalizeRefused, + 'daemon.route.maestro-deferred-settle-capture-refused': maestroDeferredSettleCaptureRefused, 'daemon.route.scroll-transport-failure': scrollWhoseGestureSendFailed, 'daemon.route.scroll-until-before-first-gesture': scrollUntilRefusedBeforeFirstGesture, 'daemon.route.scroll-then-dialog-read-refused': androidScrollThenDialogReadRefused, diff --git a/src/daemon/__tests__/request-dispatch-ledger.test.ts b/src/daemon/__tests__/request-dispatch-ledger.test.ts new file mode 100644 index 0000000000..2fd90ca093 --- /dev/null +++ b/src/daemon/__tests__/request-dispatch-ledger.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; +import { + createRequestDispatchLedger, + recordBoundMutations, + recordNestedRequests, + requestDispatchLedger, +} from '../request-dispatch-ledger.ts'; + +const REQUEST: DaemonRequest = { token: 't', session: 's', command: 'press', positionals: [] }; + +test('a bound mutation records its send once it returns; a read and a failed send do not', async () => { + const ledger = createRequestDispatchLedger(); + const bound = recordBoundMutations( + { + operations: { + tapPoint: async () => ({}), + typeText: async () => { + throw new AppError('COMMAND_FAILED', 'runner busy', { dispatched: 'no' }); + }, + captureSnapshot: async () => ({ nodes: [] }), + }, + }, + ledger, + ); + await bound.operations.captureSnapshot(); + assert.equal(ledger.dispatchedSteps, 0); + await bound.operations.tapPoint(); + await bound.operations.tapPoint(); + assert.equal(ledger.dispatchedSteps, 2); + await assert.rejects(bound.operations.typeText()); + assert.equal(ledger.dispatchedSteps, 2); +}); + +test('a request records into the ledger handed down to it, or a fresh one of its own', () => { + const handedDown = createRequestDispatchLedger(); + assert.equal( + requestDispatchLedger({ ...REQUEST, internal: { dispatchLedger: handedDown } }), + handedDown, + ); + assert.deepEqual(requestDispatchLedger(REQUEST), { dispatchedSteps: 0 }); +}); + +test('a nested request records in its own ledger and moves its sends to the parent', async () => { + const parent = createRequestDispatchLedger(); + let seen: DaemonRequest | undefined; + const invoke = recordNestedRequests(async (req) => { + seen = req; + req.internal!.dispatchLedger!.dispatchedSteps += 1; + return { ok: true, data: {} }; + }, parent); + await invoke(REQUEST); + assert.notEqual(seen?.internal?.dispatchLedger, parent); + assert.equal(parent.dispatchedSteps, 1); +}); + +test('a failed nested response keeps only its producer steps, so the parent counts each send once', async () => { + const parent = createRequestDispatchLedger(); + const failedAfter = (dispatchedSteps: number): DaemonResponse => ({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'series failed', + details: { dispatched: 'unknown', dispatchedSteps }, + }, + }); + const invoke = recordNestedRequests(async (req) => { + req.internal!.dispatchLedger!.dispatchedSteps += 2; + return req.command === 'press' ? failedAfter(3) : failedAfter(2); + }, parent); + const withProducerSteps = await invoke(REQUEST); + assert.equal(parent.dispatchedSteps, 2); + assert.deepEqual(!withProducerSteps.ok && withProducerSteps.error.details, { + dispatched: 'unknown', + dispatchedSteps: 1, + }); + const ledgerStepsOnly = await invoke({ ...REQUEST, command: 'swipe' }); + assert.equal(parent.dispatchedSteps, 4); + assert.deepEqual(!ledgerStepsOnly.ok && ledgerStepsOnly.error.details, { dispatched: 'unknown' }); +}); diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 0f323c0f9e..c6ce269e2d 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -14,6 +14,7 @@ import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../interaction/index.ts'; import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; import { createRequestRuntimeBindings } from '../request-runtime-binding.ts'; +import { createRequestDispatchLedger } from '../request-dispatch-ledger.ts'; import { createLocalLinuxToolProvider, withLinuxToolProvider } from '@agent-device/platform-linux'; import { unavailableBindDevice, @@ -290,6 +291,7 @@ test('duration-less public coordinate swipe retains Linux drag behavior', async }); let bindCount = 0; const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: { ...gateway, bind: async (request) => { diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index 2eba8ce399..47f43019e8 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -24,6 +24,7 @@ import { createRequestRuntimeBindings, ensureBoundDeviceReady, } from '../request-runtime-binding.ts'; +import { createRequestDispatchLedger } from '../request-dispatch-ledger.ts'; import { ensureDeviceReady } from '../device/device-ready.ts'; import { admitRuntimeUse } from '../runtime-admission.ts'; @@ -88,6 +89,7 @@ test('runtime readiness follows allocator claim admission', async () => { events.push('claim'); }); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim: admit, @@ -116,6 +118,7 @@ test('request runtime binding caches one broad owner and projects each declared async (_device: DeviceInfo, _owner: RuntimeOwnerRef, _intent: DeviceBindingIntent) => {}, ); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim: admit, @@ -143,6 +146,7 @@ test('request runtime binding caches one broad owner and projects each declared test('facts inspection answers admission without creating a request binding', async () => { const runtime = makeGateway(); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim, @@ -161,6 +165,7 @@ test('facts inspection answers admission without creating a request binding', as test('request binding disposes multiple owners in reverse adoption order', async () => { const runtime = makeGateway(); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim, @@ -174,6 +179,7 @@ test('request binding disposes multiple owners in reverse adoption order', async test('concurrent uses share one in-flight broad binding for the device', async () => { const runtime = makeGateway(); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim, @@ -194,6 +200,7 @@ test('concurrent uses share one in-flight broad binding for the device', async ( test('preferred absence is visible without failing while required absence fails typed', async () => { const runtime = makeGateway({ inspectAvailable: false }); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim, @@ -213,6 +220,7 @@ test('exact-owner recovery binds the persisted owner and fence without ordinary async (_device: DeviceInfo, _owner: RuntimeOwnerRef, _intent: DeviceBindingIntent) => {}, ); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtime.gateway, scope, admitDeviceClaim: admit, @@ -273,7 +281,12 @@ test('late exact-owner binding is rolled back when request cleanup already began ), shutdown: async () => {}, }; - const bindings = createRequestRuntimeBindings({ gateway, scope, admitDeviceClaim }); + const bindings = createRequestRuntimeBindings({ + gateway, + scope, + admitDeviceClaim, + dispatchLedger: createRequestDispatchLedger(), + }); const binding = bindings.bindExactDevice( selected, owner, @@ -321,7 +334,12 @@ test('late exact-owner rollback failure is secondary diagnostic evidence', async diagnostics: { emit }, progress: { report: () => {} }, }; - const bindings = createRequestRuntimeBindings({ gateway, scope, admitDeviceClaim }); + const bindings = createRequestRuntimeBindings({ + gateway, + scope, + admitDeviceClaim, + dispatchLedger: createRequestDispatchLedger(), + }); const binding = bindings.bindExactDevice( selected, owner, @@ -384,7 +402,12 @@ test('request cancellation aborts deferred exact recovery and late publication i diagnostics: { emit: vi.fn() }, progress: { report: () => {} }, }; - const bindings = createRequestRuntimeBindings({ gateway, scope: requestScope, admitDeviceClaim }); + const bindings = createRequestRuntimeBindings({ + gateway, + scope: requestScope, + admitDeviceClaim, + dispatchLedger: createRequestDispatchLedger(), + }); const acquisition = acquireDurableCaptureRecoveryAuthorityBeforeDeadline({ displayName: 'screen recording', envelope, diff --git a/src/daemon/__tests__/runtime-operation-effects.test.ts b/src/daemon/__tests__/runtime-operation-effects.test.ts new file mode 100644 index 0000000000..10f778d9e4 --- /dev/null +++ b/src/daemon/__tests__/runtime-operation-effects.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { RUNTIME_OPERATION_NAMES } from '@agent-device/contracts/runtime-operation-names'; +import { RUNTIME_OPERATION_EFFECTS } from '../runtime-operation-effects.ts'; + +// The table's `Record` type refuses a missing or unknown operation at compile time; this is the +// same rule at run time, so an operation without a declared effect cannot bind unrecorded. +test('every runtime operation declares its effect, and only runtime operations do', () => { + assert.deepEqual( + Object.keys(RUNTIME_OPERATION_EFFECTS).sort(), + [...RUNTIME_OPERATION_NAMES].sort(), + ); + for (const effect of Object.values(RUNTIME_OPERATION_EFFECTS)) { + assert.ok(effect === 'mutates' || effect === 'repeatable', effect); + } +}); diff --git a/src/daemon/daemon-request.ts b/src/daemon/daemon-request.ts index d011f62cdb..bd9063528f 100644 --- a/src/daemon/daemon-request.ts +++ b/src/daemon/daemon-request.ts @@ -8,6 +8,7 @@ import type { DaemonResponseData as PublicDaemonResponseData, } from '@agent-device/kernel/contracts'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; +import type { RequestDispatchLedger } from './request-dispatch-ledger.ts'; /** * The daemon's own request and response vocabulary: the wire shape from `@agent-device/contracts/command` (`DaemonWireRequest`) @@ -54,6 +55,11 @@ type DaemonRequestInternal = ReplayDispatchOptions & { * side-effect seam and expires the frame. */ findResolvedTarget?: PreresolvedInteractionTarget; + /** + * The ledger a nested request records its mutations in, handed down by the request that + * delegated it so those mutations count toward that request's disclosure too. + */ + dispatchLedger?: RequestDispatchLedger; }; /** diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 7d9dc8d5ed..8dd2d41955 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -18,10 +18,12 @@ import { resetGetRuntimeFixture, } from '../../../__tests__/interaction-get-runtime-fixture.ts'; import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; +import { discloseRequestDispatch } from '../../../request-dispatch-disclosure.ts'; import { createRequestDispatchLedger, - discloseRequestDispatch, -} from '../../../request-dispatch-disclosure.ts'; + recordBoundMutations, +} from '../../../request-dispatch-ledger.ts'; +import type { BindDeviceRuntime } from '../../../request-runtime-binding.ts'; import { clearAndroidObservationFixture } from '../../../__tests__/android-observation-fixture.ts'; import { handleInteractionCommands } from '../../index.ts'; import type { InteractionRouteInput } from '../types.ts'; @@ -41,13 +43,20 @@ beforeEach(() => { resetGetRuntimeFixture(); }); -/** The interaction route as the request router runs it: inside the request's dispatch seam. */ +/** + * The interaction route as the request router runs it: inside the request's dispatch seam, with + * the bound operations recording their mutations in the request's ledger. + */ async function routeInteraction(params: InteractionRouteInput) { const dispatchLedger = createRequestDispatchLedger(); + const unrecorded = params.bindDevice; + const bindDevice: BindDeviceRuntime | undefined = unrecorded + ? async (device, use) => recordBoundMutations(await unrecorded(device, use), dispatchLedger) + : undefined; return await discloseRequestDispatch( params.req, dispatchLedger, - async () => await handleInteractionCommands({ ...params, dispatchLedger }), + async () => await handleInteractionCommands({ ...params, bindDevice }), ); } diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index 271136ca79..0ca084d4ad 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -17,7 +17,6 @@ import { confirmIosOffscreenTargetVisible } from '../../offscreen-target-probe.t import { createDaemonRuntimeSessionStore } from '../../runtime-session.ts'; import { expireRefFrame } from '../../ref-frame.ts'; import { setSessionSnapshot } from '../../session-snapshot.ts'; -import { sendRecordedMutation } from '../../request-dispatch-disclosure.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput, @@ -91,7 +90,6 @@ export function createInteractionRuntimeForRoute( pairedGestureViewport: params.pairedGestureViewport, touchExecutor: params.touchExecutor, gestures: params.gestures, - dispatchLedger: params.dispatchLedger, }); } @@ -147,7 +145,7 @@ function createInteractionBackend(params: InteractionRuntimeInput): AgentDeviceB await params.confirmOffscreenTargetVisible!(node, rootViewport), } : {}), - ...touchBackendMembers(params, flags), + ...touchBackendMembers(params.touchExecutor, params.expireRefFrame, flags), }; } @@ -167,28 +165,21 @@ function gestureBackendMembers( pairedGestureViewport ?? (await gestures.gestureViewport?.(gestureContext())), performGesture: async (_context, plan): Promise => { params.expireRefFrame(); - return toBackendActionResult( - await sendRecordedMutation( - params.dispatchLedger, - async () => await gestures.performPlan(plan, gestureContext()), - ), - ); + return toBackendActionResult(await gestures.performPlan(plan, gestureContext())); }, }; } function touchBackendMembers( - params: InteractionRuntimeInput, + executor: InteractionRuntimeInput['touchExecutor'], + expireRefFrame: () => void, flags: InteractionRuntimeInput['flags'], ): Partial { - const executor = params.touchExecutor; if (!executor) return {}; const { tapPoint, tapRef, fillPoint, fillRef, longPressPoint, hoverPoint, hoverRef } = executor; const run = async (action: () => unknown): Promise => { - params.expireRefFrame(); - return toBackendActionResult( - await sendRecordedMutation(params.dispatchLedger, async () => await action()), - ); + expireRefFrame(); + return toBackendActionResult(await action()); }; return { tap: tapPoint ? (_context, point) => run(() => tapPoint(point, flags)) : undefined, diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index 7010eef56a..90a00fa6c9 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -6,7 +6,6 @@ import { type DirectIosSelectorTarget, } from '../../direct-ios-selector.ts'; import { expireRefFrame } from '../../ref-frame.ts'; -import { sendRecordedMutation } from '../../request-dispatch-disclosure.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; import type { SessionState } from '../../session-state.ts'; import { finalizeTouchInteraction } from './interaction-runtime.ts'; @@ -44,11 +43,7 @@ export async function dispatchDirectIosSelectorTap( // not-found/timeout is post-seam and does not restore the frame. expireRefFrame(session); try { - const data = - (await sendRecordedMutation( - handlerParams.dispatchLedger, - async () => await tapElementSelector(selector), - )) ?? {}; + const data = (await tapElementSelector(selector)) ?? {}; const actionFinishedAt = Date.now(); const point = readPointFromDirectSelectorTapResult(data); const publicData = transformTouchResponseData({ diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index ded46e9785..1cc82a0a63 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -8,7 +8,6 @@ import { finalizeTouchInteraction } from './interaction-runtime.ts'; import { refSnapshotFlagGuardResponse } from '../../ref-snapshot-flag-policy.ts'; import { dispatchGetViaRuntime, dispatchIsViaRuntime } from '../../selector-runtime.ts'; import { expireRefFrame } from '../../ref-frame.ts'; -import { sendRecordedMutation } from '../../request-dispatch-disclosure.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { normalizeError } from '@agent-device/kernel/errors'; import { @@ -151,13 +150,9 @@ async function runTypeTextViaRuntime( // executing so a later step cannot reuse it. R41: the bound executor already validates and // composes the retired leaf's exact result, so nothing here re-validates or re-formats it. expireRefFrame(session); - const result = await sendRecordedMutation( - params.dispatchLedger, - async () => - await boundTypeText( - req.positionals ?? [], - params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - ), + const result = await boundTypeText( + req.positionals ?? [], + params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), ); await ensureAndroidBlockingSystemDialogReady({ session, diff --git a/src/daemon/interaction/internal/types.ts b/src/daemon/interaction/internal/types.ts index 0e0b8a7fc0..9236285c0d 100644 --- a/src/daemon/interaction/internal/types.ts +++ b/src/daemon/interaction/internal/types.ts @@ -2,7 +2,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { Rect, SnapshotPreferredBackend, SnapshotState } from '@agent-device/kernel/snapshot'; import type { RequestCaptureProof } from '../../capture-disclosure.ts'; -import type { RequestDispatchLedger } from '../../request-dispatch-disclosure.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { CommandSessionStore } from '../../../runtime-contract.ts'; import type { DeferredInteractionOutcomeMark } from '../../deferred-interaction-outcome.ts'; @@ -37,8 +36,6 @@ export type InteractionRouteInput = { * press consumes no capture and must not be disclosed against an earlier request's tree (#2682). */ captureProof?: RequestCaptureProof; - /** The request's sent mutations; a failure after one never keeps a producer's `no`. */ - dispatchLedger?: RequestDispatchLedger; }; export type FindRouteInput = { @@ -99,7 +96,6 @@ export type InteractionRuntimeInput = { pairedGestureViewport?: Rect; touchExecutor?: BoundTouchExecutor; gestures?: BoundGestureExecutor; - dispatchLedger?: RequestDispatchLedger; }; export type InteractionGestureVisualization = ( diff --git a/src/daemon/request-dispatch-disclosure.ts b/src/daemon/request-dispatch-disclosure.ts index 037a6ab926..51a7be066f 100644 --- a/src/daemon/request-dispatch-disclosure.ts +++ b/src/daemon/request-dispatch-disclosure.ts @@ -9,33 +9,17 @@ import { } from '@agent-device/kernel/errors'; import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; +import type { RequestDispatchLedger } from './request-dispatch-ledger.ts'; -/** - * The device-reaching mutations of one request whose send returned. Once one did, no later failure - * of that request may claim `dispatched: no`, whatever observation or sub-step produced it. - */ -export type RequestDispatchLedger = { dispatchedSteps: number }; - -export function createRequestDispatchLedger(): RequestDispatchLedger { - return { dispatchedSteps: 0 }; -} - -/** Sends one mutation and records it in the request's ledger once the send returned. */ -export async function sendRecordedMutation( - ledger: RequestDispatchLedger | undefined, - send: () => Promise, -): Promise { - const result = await send(); - if (ledger) ledger.dispatchedSteps += 1; - return result; -} +type RecordingEffect = ReturnType; /** - * The daemon's verdict around one request, keyed by the registry's `recordingEffect`. A read - * (`observes-app`) is `no` over any producer verdict: it has no side effect, so a resend is safe. - * Otherwise a producer verdict stands until a mutation of this request was sent, after which the - * failure is `unknown` with the sent count in `details.dispatchedSteps`; `unknown` fills a failure - * no producer classified. A command without a declared effect passes through. + * The daemon's verdict around one request. Once the request's ledger holds a returned mutation, its + * failure is `unknown` with the sent count added to `details.dispatchedSteps`, whatever produced it + * and whatever the command declares. Before that, the registry's `recordingEffect` decides: a read + * (`observes-app`) is `no` over any producer verdict, because a resend repeats no app-visible + * action; a mutation keeps a producer verdict and is `unknown` when no producer classified the + * failure; a command without a declared effect passes through. */ export async function discloseRequestDispatch( req: DaemonRequest, @@ -45,30 +29,34 @@ export async function discloseRequestDispatch 0) { + discloseDispatchAfterSteps(failure, ledger.dispatchedSteps); + throw failure; + } + if (effect === undefined) throw error; if (effect === 'observes-app') throw discloseDispatch(failure, 'no'); - discloseDispatchAfterSteps(failure, ledger.dispatchedSteps); throw discloseUnclassifiedDispatch(failure, 'unknown'); } } function disclosedDetails( - effect: NonNullable>, + effect: RecordingEffect, details: ErrorWireDetails | undefined, ledger: RequestDispatchLedger, ): ErrorWireDetails | undefined { + if (ledger.dispatchedSteps > 0) + return detailsAfterDispatchedSteps(details, ledger.dispatchedSteps); + if (effect === undefined) return details; if (effect === 'observes-app') { return details?.dispatched === 'no' ? details : { ...details, dispatched: 'no' }; } - const afterSteps = detailsAfterDispatchedSteps(details, ledger.dispatchedSteps); - if (afterSteps?.dispatched !== undefined) return afterSteps; - return { ...afterSteps, dispatched: 'unknown' }; + return details?.dispatched === undefined ? { ...details, dispatched: 'unknown' } : details; } /** A failure response built before any dispatch: the requested operation never reached the device. */ diff --git a/src/daemon/request-dispatch-ledger.ts b/src/daemon/request-dispatch-ledger.ts new file mode 100644 index 0000000000..c433e49004 --- /dev/null +++ b/src/daemon/request-dispatch-ledger.ts @@ -0,0 +1,88 @@ +import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './daemon-request.ts'; +import { RUNTIME_OPERATION_EFFECTS } from './runtime-operation-effects.ts'; + +/** + * The device-reaching mutations one request sent whose send returned, its nested requests' + * included. Once one did, no later failure of that request may claim `dispatched: no`. + */ +export type RequestDispatchLedger = { dispatchedSteps: number }; + +export function createRequestDispatchLedger(): RequestDispatchLedger { + return { dispatchedSteps: 0 }; +} + +/** The ledger a request records into: the one its delegating request handed down, or its own. */ +export function requestDispatchLedger(req: DaemonRequest): RequestDispatchLedger { + return req.internal?.dispatchLedger ?? createRequestDispatchLedger(); +} + +type RuntimeOperation = (...args: never[]) => unknown; + +/** The same bound runtime, whose every `mutates` operation records its returned send in `ledger`. */ +export function recordBoundMutations>( + bound: Bound, + ledger: RequestDispatchLedger, +): Bound { + const operations: Record = {}; + for (const [name, operation] of Object.entries(bound.operations)) { + operations[name] = + typeof operation === 'function' && + RUNTIME_OPERATION_EFFECTS[name as keyof typeof RUNTIME_OPERATION_EFFECTS] === 'mutates' + ? recordingMutation(operation as RuntimeOperation, ledger) + : operation; + } + return Object.freeze({ ...bound, operations: Object.freeze(operations) }); +} + +function recordingMutation( + operation: RuntimeOperation, + ledger: RequestDispatchLedger, +): RuntimeOperation { + return (...args) => { + const result = operation(...args); + if (!(result instanceof Promise)) { + ledger.dispatchedSteps += 1; + return result; + } + return result.then((value: unknown) => { + ledger.dispatchedSteps += 1; + return value; + }); + }; +} + +/** + * Runs each nested request (a batch step, a replay action, a find's delegated action) on a ledger + * of its own, then moves its sends into `ledger`. A failed nested response keeps only the steps its + * producer counted inside a failing send, so the parent that adds its ledger counts each send once. + */ +export function recordNestedRequests( + invoke: DaemonInvokeFn, + ledger: RequestDispatchLedger, +): DaemonInvokeFn { + return async (req) => { + const nested = createRequestDispatchLedger(); + const response = await invoke({ + ...req, + internal: { ...req.internal, dispatchLedger: nested }, + }); + ledger.dispatchedSteps += nested.dispatchedSteps; + if (response.ok || nested.dispatchedSteps === 0) return response; + return withoutLedgerSteps(response, nested.dispatchedSteps); + }; +} + +function withoutLedgerSteps( + response: Extract, + ledgerSteps: number, +): DaemonResponse { + const { dispatchedSteps, ...details } = response.error.details ?? {}; + const producerSteps = typeof dispatchedSteps === 'number' ? dispatchedSteps - ledgerSteps : 0; + return { + ...response, + error: { + ...response.error, + details: producerSteps > 0 ? { ...details, dispatchedSteps: producerSteps } : details, + }, + }; +} diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 456df5c156..bfcf982189 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -74,6 +74,7 @@ import { assertDaemonPolicyAdmitsRequest, } from './daemon-policy.ts'; import type { DaemonPolicy } from '../daemon-policy-file.ts'; +import { requestDispatchLedger, type RequestDispatchLedger } from './request-dispatch-ledger.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -96,6 +97,8 @@ export type RequestExecutionScope = AsyncDisposable & { bindDevice: BindDeviceRuntime; inspectFacts: InspectDeviceRuntimeFacts; bindExactDevice: BindExactDeviceRuntime; + /** The request's mutations, recorded by every bound operation this scope hands out. */ + dispatchLedger: RequestDispatchLedger; throwIfCanceled(): void; }; @@ -108,6 +111,7 @@ export type LockedRequestScope = { bindDevice: BindDeviceRuntime; inspectFacts: InspectDeviceRuntimeFacts; bindExactDevice: BindExactDeviceRuntime; + dispatchLedger: RequestDispatchLedger; throwIfCanceled(): void; contextFromFlags( flags: CommandFlags | undefined, @@ -213,8 +217,10 @@ export async function createRequestExecutionScope(params: { locks: executionLocks, initialKeys: lockPlan.keys, }); + const dispatchLedger = requestDispatchLedger(scopedReq); const { claimAdmission, runtimeBindings } = createRequestDeviceAccess({ command, + dispatchLedger, workspace: scopedReq.meta?.cwd ?? process.cwd(), stateDir: sessionStore.resolveDaemonStateDir(), deviceRuntimeGateway: params.deviceRuntimeGateway, @@ -258,6 +264,7 @@ export async function createRequestExecutionScope(params: { { reason: 'runtime-gateway-missing' }, ); }), + dispatchLedger, throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); @@ -367,6 +374,7 @@ export async function createRequestExecutionScope(params: { */ function createRequestDeviceAccess(params: { command: string; + dispatchLedger: RequestDispatchLedger; workspace: string; stateDir: string; deviceRuntimeGateway: DeviceRuntimeGateway | undefined; @@ -392,6 +400,7 @@ function createRequestDeviceAccess(params: { runtimeBindings: createRequestRuntimeBindings({ gateway: deviceRuntimeGateway, scope: platformRequestScope, + dispatchLedger: params.dispatchLedger, admitDeviceClaim: claimAdmission.admit, admitDevice: daemonPolicy ? (device) => assertDaemonPolicyAdmitsDevice(daemonPolicy, device) @@ -552,6 +561,7 @@ export async function prepareLockedRequestScope(params: { bindDevice: scope.bindDevice, inspectFacts: scope.inspectFacts, bindExactDevice: scope.bindExactDevice, + dispatchLedger: scope.dispatchLedger, throwIfCanceled: scope.throwIfCanceled, contextFromFlags, handlerContextFromFlags: (flags, appBundleId, traceLogPath) => diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 2a75bb35b8..8884506f2a 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -24,7 +24,6 @@ import type { PlatformRequestScope } from '@agent-device/contracts/platform-runt import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; -import type { RequestDispatchLedger } from './request-dispatch-disclosure.ts'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -58,7 +57,6 @@ type RequestHandlerChainParams = { screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; hostDiagnostics?: HostDiagnostics; requestScope: PlatformRequestScope; - dispatchLedger?: RequestDispatchLedger; retainDeviceExecutionLock(deviceId: string): Promise; throwIfCanceled(): void; contextFromFlags: ( @@ -291,7 +289,6 @@ async function runInteractionHandler( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, androidObservation: params.androidObservation, - dispatchLedger: params.dispatchLedger, }), ); } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 40c67b14cd..b2158f777a 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -79,13 +79,8 @@ import { } from '@agent-device/capture-kit/screen-recording-admission-ledger'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; -import { - createRequestDispatchLedger, - discloseRequestDispatch, - refusedBeforeDispatch, - sendRecordedMutation, - type RequestDispatchLedger, -} from './request-dispatch-disclosure.ts'; +import { discloseRequestDispatch, refusedBeforeDispatch } from './request-dispatch-disclosure.ts'; +import { recordNestedRequests } from './request-dispatch-ledger.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import { restrictDeviceInventoryToDaemonPolicy } from './daemon-policy.ts'; @@ -310,17 +305,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { allowReplayActions: boolean; }): Promise { const { lockedScope, providerScope, allowReplayActions } = params; - const dispatchLedger = createRequestDispatchLedger(); return await discloseRequestDispatch( lockedScope.req, - dispatchLedger, - async () => - await routeLockedRequest({ - lockedScope, - providerScope, - allowReplayActions, - dispatchLedger, - }), + lockedScope.dispatchLedger, + async () => await routeLockedRequest({ lockedScope, providerScope, allowReplayActions }), ); } @@ -328,9 +316,9 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { lockedScope: LockedRequestScope; providerScope: RequestPlatformProviderScope; allowReplayActions: boolean; - dispatchLedger: RequestDispatchLedger; }): Promise { - const { lockedScope, providerScope, allowReplayActions, dispatchLedger } = params; + const { lockedScope, providerScope, allowReplayActions } = params; + const { dispatchLedger } = lockedScope; const requestScope = createPlatformRequestScope(lockedScope.req); const handlerResponse = await runRequestHandlerChain({ req: lockedScope.req, @@ -345,7 +333,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerAppCatalog, invoke: handleRequest, invokeReplayAction: allowReplayActions - ? createReplayScopedActionInvoker(lockedScope, providerScope) + ? recordNestedRequests( + createReplayScopedActionInvoker(lockedScope, providerScope), + dispatchLedger, + ) : undefined, providerScope, androidObservation, @@ -360,7 +351,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { screenRecordingAdmissionLedger, hostDiagnostics, requestScope, - dispatchLedger, retainDeviceExecutionLock: lockedScope.retainDeviceExecutionLock, throwIfCanceled: lockedScope.throwIfCanceled, contextFromFlags: lockedScope.handlerContextFromFlags, @@ -372,7 +362,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { logPath: lockedScope.logPath, sessionStore, androidObservation, - dispatchLedger, }); } @@ -498,9 +487,8 @@ async function dispatchGenericForLockedScope(params: { logPath: string; sessionStore: SessionStore; androidObservation: AndroidObservationAdapter; - dispatchLedger: RequestDispatchLedger; }): Promise { - const { lockedScope, logPath, sessionStore, androidObservation, dispatchLedger } = params; + const { lockedScope, logPath, sessionStore, androidObservation } = params; const session = sessionStore.get(lockedScope.sessionName); if (!session) { return noActiveSessionError(); @@ -520,7 +508,6 @@ async function dispatchGenericForLockedScope(params: { bindDevice: lockedScope.bindDevice, }); if (!runtimeExecution.ok) return refusedBeforeDispatch(runtimeExecution.response); - const execute = runtimeExecution.execute; const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ @@ -530,8 +517,7 @@ async function dispatchGenericForLockedScope(params: { logPath, sessionStore, contextFromFlags: lockedScope.contextFromFlags, - executePlatformCommand: async (execution) => - await sendRecordedMutation(dispatchLedger, async () => await execute(execution)), + executePlatformCommand: runtimeExecution.execute, androidObservation, ...(runtimeExecution.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), }); diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index 88bd7b31a7..65f2720f90 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -16,6 +16,7 @@ import { import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { ensureDeviceReady } from './device/device-ready.ts'; +import { recordBoundMutations, type RequestDispatchLedger } from './request-dispatch-ledger.ts'; import type { ManagedRequestAdmission, ResolveManagedRequestLease, @@ -110,10 +111,15 @@ export type RequestRuntimeBindings = AsyncDisposable & bindExactDevice: BindExactDeviceRuntime; }>; -/** Owns request runtime bindings while exposing only the requested operation projection. */ +/** + * Owns request runtime bindings while exposing only the requested operation projection. Every + * projection records its mutations in the request's `dispatchLedger`, so no route reaches the + * device without its mutations counting toward the request's disclosure. + */ export function createRequestRuntimeBindings(params: { gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; + dispatchLedger: RequestDispatchLedger; resolveManagedLease?: ResolveManagedRequestLease; admitDeviceClaim: ( device: DeviceInfo, @@ -150,7 +156,10 @@ export function createRequestRuntimeBindings(params: { if (bindings.get(key) === bindingPromise) bindings.delete(key); }); } - return narrowDeviceBinding(await bindingPromise, use); + return recordBoundMutations( + narrowDeviceBinding(await bindingPromise, use), + params.dispatchLedger, + ); }; const bindExactDevice: BindExactDeviceRuntime = async (device, owner, fence, use, scope) => { @@ -174,7 +183,7 @@ export function createRequestRuntimeBindings(params: { : await params.gateway.bind({ device, intent, scope }); const adopted = await adoptExactBinding(cleanups, published, scope); const binding = managed ? adopted : await admitBinding(adopted, intent); - const bound = narrowDeviceBinding(binding, use); + const bound = recordBoundMutations(narrowDeviceBinding(binding, use), params.dispatchLedger); managed?.activate(); if (managed) managedReadiness.set(bound, managed.ensureReady); return bound; diff --git a/src/daemon/runtime-operation-effects.ts b/src/daemon/runtime-operation-effects.ts new file mode 100644 index 0000000000..d62f8635e8 --- /dev/null +++ b/src/daemon/runtime-operation-effects.ts @@ -0,0 +1,97 @@ +import type { RuntimeOperationKey } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; + +/** + * What one bound runtime operation does to the device when its send returns. `mutates`: the app or + * device changed, so a resend may repeat an app-visible action. `repeatable`: a resend repeats no + * app-visible action — reads, idempotent readiness and preparation, and host-side recorder, log, + * probe, and profiler controls whose repeat the device refuses or ignores. + */ +export type RuntimeOperationEffect = 'mutates' | 'repeatable'; + +/** Every bound operation's effect; the request binding records each `mutates` send it returns. */ +export const RUNTIME_OPERATION_EFFECTS: Readonly< + Record, RuntimeOperationEffect> +> = Object.freeze({ + acceptAlert: 'mutates', + actionButton: 'mutates', + appLogCleanup: 'repeatable', + appLogDoctor: 'repeatable', + appLogInspect: 'repeatable', + appLogReattach: 'repeatable', + appLogStart: 'repeatable', + appState: 'repeatable', + appSwitcher: 'mutates', + applyRuntimeHints: 'mutates', + audioProbeCleanup: 'repeatable', + audioProbeQuery: 'repeatable', + audioProbeReattach: 'repeatable', + audioProbeStart: 'repeatable', + awaitAlert: 'repeatable', + back: 'mutates', + bootTarget: 'mutates', + bootTargetHeadless: 'mutates', + captureScreenshot: 'repeatable', + captureSnapshot: 'repeatable', + captureSnapshotWithCustomActions: 'repeatable', + captureSnapshotWithoutActiveApp: 'repeatable', + clearRuntimeHints: 'mutates', + closeApplication: 'mutates', + configureProviderPortReverse: 'repeatable', + deployApp: 'mutates', + deployMaterializedApp: 'mutates', + dismissAlert: 'mutates', + ensureReady: 'repeatable', + fillPoint: 'mutates', + fillRef: 'mutates', + finalizeApplicationClose: 'mutates', + findText: 'repeatable', + focusPoint: 'mutates', + gestureViewport: 'repeatable', + home: 'mutates', + hoverPoint: 'mutates', + hoverRef: 'mutates', + keyboardDismiss: 'mutates', + keyboardEnter: 'mutates', + keyboardStatus: 'repeatable', + listApps: 'repeatable', + longPressPoint: 'mutates', + materializeAppSource: 'repeatable', + networkDump: 'repeatable', + openApplication: 'mutates', + perfFrames: 'repeatable', + perfMemorySample: 'repeatable', + perfMemorySnapshot: 'repeatable', + perfNativeCaptureCleanup: 'repeatable', + perfNativeCaptureReattach: 'repeatable', + perfNativeCaptureStart: 'repeatable', + perfProfileReport: 'repeatable', + performDirectionalFlingPlan: 'mutates', + performGesturePlan: 'mutates', + performMultiTouchGesturePlan: 'mutates', + performTargetAuthoredDrag: 'mutates', + prepareAppleRunner: 'repeatable', + prepareApplicationOpen: 'repeatable', + readAlert: 'repeatable', + readClipboard: 'repeatable', + readSetting: 'repeatable', + readTextAtPoint: 'repeatable', + resolveOpenTarget: 'repeatable', + screenRecordingCleanup: 'repeatable', + screenRecordingReattach: 'repeatable', + screenRecordingStart: 'repeatable', + scrollDirection: 'mutates', + sendPushNotification: 'mutates', + setFoldPose: 'mutates', + setOrientation: 'mutates', + setSetting: 'mutates', + setViewport: 'mutates', + shutdownTarget: 'mutates', + tapElementSelector: 'mutates', + tapPoint: 'mutates', + tapRef: 'mutates', + triggerAppEvent: 'mutates', + tvRemote: 'mutates', + typeText: 'mutates', + writeClipboard: 'mutates', +}); diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 809db0ba13..7b71c9259d 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -22,6 +22,7 @@ import { createLimrunRuntime } from '@agent-device/provider-limrun'; import { describe, expect, test, vi } from 'vitest'; import { handleSessionStateCommands } from './daemon/handlers/session-state.ts'; import { createRequestRuntimeBindings } from './daemon/request-runtime-binding.ts'; +import { createRequestDispatchLedger } from './daemon/request-dispatch-ledger.ts'; import { makeSessionStore } from './__tests__/test-utils/store-factory.ts'; import { withTestDeviceInventory } from './__tests__/test-utils/device-inventory-gateways.ts'; import { createComposedPlatformRuntimeGateway } from './platform-runtime-gateway.ts'; @@ -341,6 +342,7 @@ describe('composed platform runtime gateway', () => { }); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: runtimeGateway, scope, admitDeviceClaim: async () => {}, diff --git a/test/integration/provider-scenarios/managed-request-admission.fixtures.ts b/test/integration/provider-scenarios/managed-request-admission.fixtures.ts index 98d97ee4e2..67f3127542 100644 --- a/test/integration/provider-scenarios/managed-request-admission.fixtures.ts +++ b/test/integration/provider-scenarios/managed-request-admission.fixtures.ts @@ -16,6 +16,7 @@ import { import { createDeviceClaimAdmission } from '../../../src/daemon/device/device-claim-admission.ts'; import { acquireAllocatorHeldDeviceClaim } from '../../../src/daemon/device/device-claim-allocator.ts'; import { createRequestRuntimeBindings } from '../../../src/daemon/request-runtime-binding.ts'; +import { createRequestDispatchLedger } from '../../../src/daemon/request-dispatch-ledger.ts'; import { managedCommandHorizon } from '../../../src/daemon/managed-device-allocation/command-horizon.ts'; import type { ManagedLeaseAdmission } from '../../../src/daemon/managed-device-allocation/lease-admission.ts'; import { @@ -162,6 +163,7 @@ export async function setupRequest( signal: (options.controller ?? new AbortController()).signal, }; const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: bindingGateway, scope, admitDeviceClaim: claims.admit, diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index 52f59d413f..cf618d1f0b 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -11,6 +11,7 @@ import type { import type { DeviceInfo } from '@agent-device/kernel/device'; import { handleAppDeploymentCommand } from '../../../src/daemon/handlers/session-app-deployment.ts'; import { createRequestRuntimeBindings } from '../../../src/daemon/request-runtime-binding.ts'; +import { createRequestDispatchLedger } from '../../../src/daemon/request-dispatch-ledger.ts'; import { makeSessionStore } from '../../../src/__tests__/test-utils/store-factory.ts'; import { mkdtempForTestSync } from '../../../src/__tests__/test-utils/tmp-dir.ts'; import { createComposedPlatformRuntimeGateway } from '../../../src/platform-runtime-gateway.ts'; @@ -39,6 +40,7 @@ function createProviderDeploymentAdmission(params: { await gateway.bind(...args), ); const bindings = createRequestRuntimeBindings({ + dispatchLedger: createRequestDispatchLedger(), gateway: { inspectFacts, bind, shutdown: async () => {} }, scope: { signal: new AbortController().signal, From 101f80ffaf5a6c6a9b921b355c8fa376edb520b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:11:10 +0200 Subject: [PATCH 24/42] fix(daemon): a batch reports no only when no executed step was a mutation Each batch step ran as a nested request on a fresh ledger, and the batch descriptor declares no recording effect, so the router passed the batch response through with the failing step's `dispatched: no`. `[press A (lands), press 'label=Missing']` reported `no` with `executed: 1`, and a retry pressed A twice. The router now hands every nested request it invokes (batch steps, find's delegated click and fill) a ledger of its own through recordNestedRequests and moves its sends into the parent's ledger, as it already did for replay actions. The batch failure then reports `unknown` with the executed steps' sends in details.dispatchedSteps. A failed nested response keeps only the steps its producer counted inside the failing send, so the parent counts each send once. Rows: daemon.route.batch-mutation-then-refused-step, daemon.route.batch-read-then-refused-step. Mutation: passing handleRequest as the chain's invoke fails the mutation-then-refused row. --- contracts/fixtures/dispatch-disclosure.json | 12 ++++++ .../request-dispatch-disclosure.test.ts | 37 +++++++++++++++++++ src/daemon/request-router.ts | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 78fcfdbb83..5677cebc5c 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -89,6 +89,18 @@ "trigger": "a Maestro replay (session route): back (stability deferred) returned, then the deferred settle capture the next command takes was refused with RUNNER_BUSY (no); the replay reports unknown with details.dispatchedSteps 1", "dispatched": "unknown" }, + { + "id": "daemon.route.batch-mutation-then-refused-step", + "producer": "daemon", + "trigger": "batch [press lands, press refused before send]: the failing step reports no, but an executed step mutated; the batch reports unknown with details.dispatchedSteps 1", + "dispatched": "unknown" + }, + { + "id": "daemon.route.batch-read-then-refused-step", + "producer": "daemon", + "trigger": "batch [get, press refused before send]: no executed step mutated, so the failing step's no stands", + "dispatched": "no" + }, { "id": "maestro-direct.fallback.pre-send-refusal", "producer": "daemon", diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts index 7b9466a002..e6c0b6484d 100644 --- a/src/daemon/__tests__/request-dispatch-disclosure.test.ts +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -66,6 +66,7 @@ const EMAIL_FIELD = { /** The operations the lifecycle gateway lacks that these rows drive, one spy each. */ const routeOperationSpies = { + tapPoint: vi.fn(async () => undefined), focusPoint: vi.fn(async () => undefined), typeText: vi.fn(async () => undefined), back: vi.fn(async () => undefined), @@ -239,10 +240,46 @@ async function maestroDeferredSettleCaptureRefused(): Promise { throw failure; } +async function batchOf( + steps: readonly { command: string; positionals: string[] }[], +): Promise { + captureNodes([CONTINUE_BUTTON]); + const failure = await route(makeIosAppSession(SESSION), { + command: 'batch', + positionals: [], + flags: { batchSteps: steps.map((step) => ({ ...step, flags: {} })) }, + }).catch((error: unknown) => error); + assert.ok(failure instanceof AppError); + assert.equal(failure.details?.executed, 1); + return failure; +} + +async function batchMutationThenRefusedStep(): Promise { + const failure = await batchOf([ + { command: 'press', positionals: ['50', '40'] }, + { command: 'press', positionals: ['label="Missing"'] }, + ]); + assert.equal(routeOperationSpies.tapPoint.mock.calls.length, 1); + assert.equal(failure.details?.dispatchedSteps, 1); + throw failure; +} + +async function batchReadThenRefusedStep(): Promise { + const failure = await batchOf([ + { command: 'get', positionals: ['text', 'label="Continue"'] }, + { command: 'press', positionals: ['label="Missing"'] }, + ]); + assert.equal(routeOperationSpies.tapPoint.mock.calls.length, 0); + assert.equal(failure.details?.dispatchedSteps, undefined); + throw failure; +} + const DRIVERS: Record Promise> = { 'daemon.route.find-type-refused-after-focus': findTypeRefusedAfterFocus, 'daemon.route.session-close-then-finalize-refused': closeThenFinalizeRefused, 'daemon.route.maestro-deferred-settle-capture-refused': maestroDeferredSettleCaptureRefused, + 'daemon.route.batch-mutation-then-refused-step': batchMutationThenRefusedStep, + 'daemon.route.batch-read-then-refused-step': batchReadThenRefusedStep, 'daemon.route.scroll-transport-failure': scrollWhoseGestureSendFailed, 'daemon.route.scroll-until-before-first-gesture': scrollUntilRefusedBeforeFirstGesture, 'daemon.route.scroll-then-dialog-read-refused': androidScrollThenDialogReadRefused, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index b2158f777a..c2c979168e 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -331,7 +331,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeRequiredIds, cloudArtifactProvider, providerAppCatalog, - invoke: handleRequest, + invoke: recordNestedRequests(handleRequest, dispatchLedger), invokeReplayAction: allowReplayActions ? recordNestedRequests( createReplayScopedActionInvoker(lockedScope, providerScope), From 61c06e38184de3e01f3bc3b33e3a498b6851e8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:11:45 +0200 Subject: [PATCH 25/42] refactor(provider-webdriver): disclose through the kernel's DispatchDisclosure type The transport declared its own 'no' | 'unknown' union because the kernel type had not landed when #3070 merged. After the rebase both exist; the transport now uses the kernel's DispatchDisclosure, so the vocabulary has one declaration. --- packages/provider-webdriver/src/webdriver-transport.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/provider-webdriver/src/webdriver-transport.ts b/packages/provider-webdriver/src/webdriver-transport.ts index aa1e28993c..90eb722a73 100644 --- a/packages/provider-webdriver/src/webdriver-transport.ts +++ b/packages/provider-webdriver/src/webdriver-transport.ts @@ -1,5 +1,5 @@ import { setTimeout as sleep } from 'node:timers/promises'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, type DispatchDisclosure } from '@agent-device/kernel/errors'; import { agentDeviceRequestHeaders } from './request-headers.ts'; import { basicAuthHeader, trimLeadingSlash, withTrailingSlash } from './webdriver-utils.ts'; @@ -34,11 +34,7 @@ export function isWebDriverRequestTimeout(error: unknown): error is AppError { * that a request executed, so a failure is either `no` (the request never reached the driver) or * `unknown`. */ -type WebDriverDispatchDisclosure = 'no' | 'unknown'; - -function dispatchDisclosure(dispatched: WebDriverDispatchDisclosure): { - dispatched: WebDriverDispatchDisclosure; -} { +function dispatchDisclosure(dispatched: DispatchDisclosure): { dispatched: DispatchDisclosure } { return { dispatched }; } From 56d4c28393c578e249651d132669e611f6714b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:13:43 +0200 Subject: [PATCH 26/42] test(provider-webdriver): drive the three WebDriver disclosure rows through the real transport The webdriver rows waited on #3070 behind `implementedBy`. With it merged, the transport test owns them (`webdriver.` prefix): each sends a mutating POST through WebDriverTransport and the real fetch to a local socket. A port nothing listens on is refused before send (`no`); a driver that reads the body and never answers hits the transport deadline after send (`unknown`); a driver that answers 503 received the request (`unknown`). The row markers are gone and the triggers name what each driver does. The contracts fixtures subpath is a test-only import of the provider package; the eager-closure budgets are unchanged. Rows: webdriver.connect-refused, webdriver.timeout-after-send, webdriver.http-5xx-after-send. Mutations: the connect-refused error disclosing `unknown` fails the refused row; the timeout error disclosing `no` fails the timeout row; dropping the >= 500 disclosure fails the 5xx row. --- contracts/fixtures/dispatch-disclosure.json | 15 ++-- .../src/dispatch-disclosure.fixtures.ts | 1 + .../src/webdriver-transport.test.ts | 75 +++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 5677cebc5c..2961380bea 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -388,22 +388,19 @@ { "id": "webdriver.connect-refused", "producer": "webdriver", - "trigger": "connect-refused", - "dispatched": "no", - "implementedBy": "fix/webdriver-no-mutation-resend" + "trigger": "a mutating POST to a port nothing listens on: the connection was refused before any byte of the request was sent", + "dispatched": "no" }, { "id": "webdriver.timeout-after-send", "producer": "webdriver", - "trigger": "timeout-after-send", - "dispatched": "unknown", - "implementedBy": "fix/webdriver-no-mutation-resend" + "trigger": "a mutating POST whose body the driver read and never answered: the transport deadline aborted it after send", + "dispatched": "unknown" }, { "id": "webdriver.http-5xx-after-send", "producer": "webdriver", - "trigger": "http-5xx-after-send", - "dispatched": "unknown", - "implementedBy": "fix/webdriver-no-mutation-resend" + "trigger": "a mutating POST the driver answered 503: it received the request, but not whether the mutation completed", + "dispatched": "unknown" } ] diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index ab6ae64627..95460e8d5c 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -53,6 +53,7 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'android-helper.': 'packages/platform-android/src/__tests__/dispatch-disclosure.test.ts', 'android-helper.gesture-session.': 'packages/platform-android/src/__tests__/touch-helper-session.test.ts', + 'webdriver.': 'packages/provider-webdriver/src/webdriver-transport.test.ts', 'maestro-direct.': 'src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts', }; diff --git a/packages/provider-webdriver/src/webdriver-transport.test.ts b/packages/provider-webdriver/src/webdriver-transport.test.ts index f003ebff64..925360a60c 100644 --- a/packages/provider-webdriver/src/webdriver-transport.test.ts +++ b/packages/provider-webdriver/src/webdriver-transport.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import http from 'node:http'; import net, { type AddressInfo } from 'node:net'; import { afterEach, test, vi } from 'vitest'; @@ -7,6 +8,11 @@ import { withDiagnosticsScope, } from '@agent-device/host-kit/diagnostics'; import { AppError } from '@agent-device/kernel/errors'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { WebDriverTransport, isWebDriverConnectRefused, @@ -356,3 +362,72 @@ test('a pre-connect code is read to the depth cap and not beyond', async () => { assert.equal(await dispatchedAfter(fetchFailedWith(nestedCause('ECONNREFUSED', 3))), 'no'); assert.equal(await dispatchedAfter(fetchFailedWith(nestedCause('ECONNREFUSED', 4))), 'unknown'); }); + +// contracts/fixtures/dispatch-disclosure.json, webdriver rows: each sends a mutating POST through the +// real transport and fetch to a local socket, and asserts the `details.dispatched` it fails with. + +async function postActions(endpoint: string, timeoutMs = 5_000): Promise { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint, + requestPolicy: { timeoutMs, retryDelayMs: 1 }, + }); + return await transport.requestValue('POST', '/session/wd-1/actions', { actions: [] }); +} + +async function postToRefusedPort(): Promise { + return await postActions(`http://127.0.0.1:${await refusedPort()}/wd/hub/`); +} + +async function postThatTimesOutAfterSend(): Promise { + const driver = await localDriver((request) => request.resume()); + try { + return await postActions(driver.endpoint, 50); + } finally { + assert.equal(driver.requests(), 1); + await driver.close(); + } +} + +async function postAnswered5xx(): Promise { + const driver = await localDriver((request, response) => { + request.resume(); + request.on('end', () => { + response.writeHead(503, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ value: { error: 'unknown error', message: 'grid down' } })); + }); + }); + try { + return await postActions(driver.endpoint); + } finally { + assert.equal(driver.requests(), 1); + await driver.close(); + } +} + +const DRIVERS: Record Promise> = { + 'webdriver.connect-refused': postToRefusedPort, + 'webdriver.timeout-after-send': postThatTimesOutAfterSend, + 'webdriver.http-5xx-after-send': postAnswered5xx, +}; + +const ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every webdriver dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); +}); + +for (const row of ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + return true; + }); + }); +} From 319e1550dad3c47d2d0e0eaf63352408c6ad7b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:14:18 +0200 Subject: [PATCH 27/42] docs(adr): state the read-only rule and the remaining disclosure gaps ADR 0011 now describes the one request ledger: operation effects declared once in RUNTIME_OPERATION_EFFECTS, bound operations recording their own sends, and nested requests moving theirs into the parent's. `no` for a read is defined as "a resend repeats no app-visible action": the registry has no trait that separates a pure read from a device control, so record, trace, and perf stay `observes-app`, and their recorder and profiler controls are declared `repeatable` at the operation level. The section lists the remaining gaps: failures before the router's locked scope (session, lock, lease, and policy admission) and the public runBatch with a caller-supplied invoke. The rewrite also removes the code spans that were hard-wrapped mid-token (`'mutates- app'`, `'observes- app'`) and the Maestro-port and `implementedBy` sentences, which no longer apply. commands.md states the read rule and the batch/replay rule. --- .../0011-interaction-guarantee-contract.md | 59 +++++++++++++------ website/docs/docs/commands.md | 3 +- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index e3b6cd9544..f1384ba4d1 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -153,23 +153,48 @@ without needing a simulator. `contracts/fixtures/dispatch-disclosure.json` is the table for `AppErrorDetails.dispatched` on interaction failures: one row per producer event, each with the value it must leave. The field has two values: `no` (the -operation provably never reached the device, so a resend is safe) and -`unknown` (it may have landed, so observe before resending). Only a producer -that refuses before dispatch (target resolution, admission, a runner pre-send -refusal) may say `no`; no producer can prove execution on its failure path, so -there is no third value. The daemon's request router fills `unknown` once, -around every routed command the registry declares `recordingEffect: 'mutates- -app'`, for a failure no producer classified. It keeps a producer's value until -a mutation of the same request was sent: after that, a later failure (a post- -action read, a settle capture, a later sub-step) is `unknown` with the sent -count in `details.dispatchedSteps`, because a wrong `no` makes a consumer -resend an action that already ran, while a wrong `unknown` only costs an -observation. The Maestro port applies the same rule per Maestro command. The -one exception is a read-only command (registry `recordingEffect: 'observes- -app'`): the daemon sets `no` over any producer value, because a read has no -side effect and is always safe to resend. Each row without `implementedBy` -names its driver file by id prefix, and that file drives the real producer; a -row marked `implementedBy` waits for the branch that ships it. +operation never reached the device, or the command is a read, so a resend is +safe) and `unknown` (it may have landed, so observe before resending). Only a +producer that refuses before dispatch (target resolution, admission, a runner +pre-send refusal) may say `no`; no producer can prove execution on its failure +path, so there is no third value. + +Each request owns one dispatch ledger. Every bound runtime operation declares +its effect once, in `RUNTIME_OPERATION_EFFECTS`, a record over the runtime +operation keys, so an operation cannot bind without one. The request binding +records each `mutates` operation in the request's ledger when its send +returns, so no route sends a mutation outside the ledger. A nested request (a +batch step, a replay action, a delegated `find` click or fill) records into a +ledger of its own and moves its sends into its parent's. + +The request router discloses around every routed command. Once the ledger +holds a sent mutation, a failure is `unknown` with the sent count in +`details.dispatchedSteps`, whatever produced it and whatever the command +declares: a wrong `no` makes a consumer resend an action that already ran, +while a wrong `unknown` only costs an observation. Before that, the registry's +`recordingEffect` decides. For `'mutates-app'` the router keeps a producer's +value and fills `unknown` for a failure no producer classified. For +`'observes-app'` it sets `no` over any producer value. A command with no +declared effect passes through. + +`no` for a read means that a resend repeats no app-visible action. The +registry declares `record`, `trace`, and `perf` as `'observes-app'`, although +their recorder and profiler controls reach the device: no registry trait +separates a pure read from a device control, and those controls are declared +`repeatable` at the operation level, because the device refuses or ignores a +repeat. A producer that runs several device inputs inside one bound operation +(an Android double tap, an iOS press series) counts them in +`details.dispatchedSteps` itself, because the ledger sees one operation. + +Each row names its driver file by id prefix, and that file drives the real +producer. + +Remaining gaps: a failure before the router's locked scope (session +resolution, lock acquisition, lease and daemon-policy admission) never reaches +the disclosure and carries no `dispatched`. It sends nothing, but a consumer +must read the absent field as `unknown`. The public `agent-device/batch` +`runBatch` has no ledger when a caller supplies its own `invoke`, so a batch run +outside the daemon keeps the failing step's value. For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 383122e636..7a7b566877 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -494,7 +494,8 @@ When an interaction fails, read `error.details.dispatched` before you retry: - `no`: the action never reached the device. Retry it as it is. - `unknown`: the action may have landed. Take a snapshot before you retry; a blind retry can tap, type, or navigate twice. -A read-only command such as `get`, `snapshot`, or `wait` reports `no`: it changes nothing, so a retry is safe. +A read-only command such as `get`, `snapshot`, or `wait` reports `no`: a retry repeats no action the app can see. This includes `record`, `trace`, and `perf`, whose recorder and profiler controls the device refuses to repeat. +Once any step of a request reached the device, its failure is `unknown`, and `error.details.dispatchedSteps` counts those steps. A `batch` or a replay reports `no` only when none of its executed steps changed the app. A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. `type` accepts text only. Do not pass `@ref` to `type`; use `fill @ref "text"` to target a field directly, or `press @ref` then `type "text"` to append in the focused field. If `type` reports `TEXT_INPUT_NOT_FOCUSED`, focus a visible text input and retry; when accessibility does not expose the input, use a coordinate focus command before typing. From 33a675f8368d7426299fc50f25652f6328ef5979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:15:48 +0200 Subject: [PATCH 28/42] fix(apple-runner): a press series counts completed taps, not sequence chunks details.dispatchedSteps of runApplePressSeries counted sequence chunks while every other series counts device inputs. It now counts the taps the runner reported completed: across finished chunks when a later chunk is refused, plus the failing chunk's own completed steps when a step inside it fails. The ios-runner.series.later-chunk-refused row now asserts 20, and its trigger says so. The runner disclosure test header names its third category: pre-send validation that refuses before anything is sent. Mutations: counting chunks fails the later-chunk row (1, not 20); dropping the failing chunk's completed steps fails the new runner-sequence test (20, not 22). --- contracts/fixtures/dispatch-disclosure.json | 2 +- .../runner-dispatch-disclosure.test.ts | 5 ++-- .../runner/__tests__/runner-sequence.test.ts | 26 +++++++++++++++++++ .../src/runner/runner-sequence.ts | 12 ++++++--- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 2961380bea..957f71829a 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -214,7 +214,7 @@ { "id": "ios-runner.series.later-chunk-refused", "producer": "ios-runner", - "trigger": "press --count 25 splits into two sequence chunks; the first ran and the second was refused with RUNNER_BUSY; the series reports unknown with details.dispatchedSteps 1", + "trigger": "press --count 25 splits into two sequence chunks; the first completed its 20 taps and the second was refused with RUNNER_BUSY; the series reports unknown with details.dispatchedSteps 20, one per completed tap", "dispatched": "unknown" }, { diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 8961a13cbe..012476536c 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -27,7 +27,8 @@ import { // contracts/fixtures/dispatch-disclosure.json, ios-runner rows: each row drives the real send stack // (executeRunnerCommandWithSession → transport fetch) or the real lost-response recovery against a -// scripted fake runner, and asserts the `details.dispatched` the failure leaves with. +// scripted fake runner, or the pre-send validation that refuses before anything is sent, and +// asserts the `details.dispatched` the failure leaves with. let server: FakeRunnerServer | undefined; @@ -117,7 +118,7 @@ async function pressSeriesRefusedOnSecondChunk(): Promise { ); } catch (error) { assert.ok(error instanceof AppError); - assert.equal(error.details?.dispatchedSteps, 1); + assert.equal(error.details?.dispatchedSteps, 20); assert.equal(server.requests.filter((request) => request.command === 'sequence').length, 2); throw error; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-sequence.test.ts b/packages/platform-apple/src/runner/__tests__/runner-sequence.test.ts index c534db5418..b7641aec7f 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-sequence.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-sequence.test.ts @@ -6,9 +6,11 @@ import { SEQUENCEABLE_RUNNER_STEP_KINDS, buildRunnerSequenceCommand, parseRunnerSequenceResult, + runApplePressSeries, validateRunnerSequenceSteps, } from '../runner-sequence.ts'; import type { RunnerSequenceStep } from '../runner-contract.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; function tap(x: number, y: number): RunnerSequenceStep { return { kind: 'tap', x, y }; @@ -173,3 +175,27 @@ test('parseRunnerSequenceResult infers a failure from sequenceResults when faile }, ); }); + +test('a step failure in a later chunk counts the taps every chunk completed', async () => { + const ok = (count: number) => Array.from({ length: count }, () => ({ ok: true, kind: 'tap' })); + const answers = [ + { completedSteps: 20, sequenceResults: ok(20) }, + { completedSteps: 2, sequenceResults: [...ok(2), { ok: false, kind: 'tap' }] }, + ]; + await assert.rejects( + runApplePressSeries( + IOS_SIMULATOR, + { x: 10, y: 20 }, + { button: 'primary', count: 25, intervalMs: 0, holdMs: 0, jitterPx: 0, doubleTap: false }, + undefined, + async () => answers.shift()!, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.failedStepIndex, 22); + assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.dispatchedSteps, 22); + return true; + }, + ); +}); diff --git a/packages/platform-apple/src/runner/runner-sequence.ts b/packages/platform-apple/src/runner/runner-sequence.ts index e38f971c4e..7098f41ed6 100644 --- a/packages/platform-apple/src/runner/runner-sequence.ts +++ b/packages/platform-apple/src/runner/runner-sequence.ts @@ -168,15 +168,13 @@ export async function runApplePressSeries( let completedSteps = 0; const sequenceResults: unknown[] = []; let stepOffset = 0; - let dispatchedChunks = 0; for (const command of commands) { let result: Record; try { result = await runCommand(command); } catch (error) { - throw discloseDispatchAfterSteps(error, dispatchedChunks); + throw discloseDispatchAfterSteps(error, completedSteps); } - dispatchedChunks += 1; first ??= result; last = result; let parsed; @@ -186,7 +184,7 @@ export async function runApplePressSeries( // The runner reports an index local to its chunk; callers need the global series index. throw discloseDispatchAfterSteps( remapSequenceErrorStepIndex(error, stepOffset), - dispatchedChunks, + completedSteps + chunkCompletedSteps(error), ); } completedSteps += parsed.completedSteps; @@ -254,6 +252,12 @@ function buildPressSteps( }); } +/** The inputs a failed chunk completed before its failing step, as the runner reported them. */ +function chunkCompletedSteps(error: unknown): number { + const completed = error instanceof AppError ? error.details?.completedSteps : undefined; + return typeof completed === 'number' ? completed : 0; +} + function remapSequenceErrorStepIndex(error: unknown, stepOffset: number): unknown { if (stepOffset === 0 || !(error instanceof AppError) || !error.details) return error; const localIndex = error.details.failedStepIndex; From 49cb9e14c68cc40cba78c637f158109cae4af29c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:16:46 +0200 Subject: [PATCH 29/42] chore(gates): drop the implementedBy row marker and its check The webdriver rows were the only rows naming another branch; they now have a driver, so the field, its ownership filter, and the gate assertion that only webdriver rows could carry it have no remaining use. Every row must now have a driver file. --- packages/contracts/src/dispatch-disclosure.fixtures.ts | 10 +++------- packages/contracts/src/dispatch-disclosure.test.ts | 4 +--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/dispatch-disclosure.fixtures.ts b/packages/contracts/src/dispatch-disclosure.fixtures.ts index 95460e8d5c..fc6caf549f 100644 --- a/packages/contracts/src/dispatch-disclosure.fixtures.ts +++ b/packages/contracts/src/dispatch-disclosure.fixtures.ts @@ -21,8 +21,6 @@ export type DispatchDisclosureRow = { dispatched: DispatchDisclosure; /** Direct iOS selector tap rows: whether the failure delegates to the tree path, which taps again. */ fallsBack?: boolean; - /** A branch that ships this row's producer; the row is not owned here until it merges. */ - implementedBy?: string; }; const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url)); @@ -34,8 +32,7 @@ export const DISPATCH_DISCLOSURE_TABLE_PATH = path.join( /** * The test file that drives each row through its real producer, keyed by row-id prefix; the - * longest matching prefix owns the row. A row naming `implementedBy` has no owner until that branch - * lands. Each driver file runs one test per owned row with the loop `for (const row of ) {` + * longest matching prefix owns the row. Each driver file runs one test per owned row with the loop `for (const row of ) {` * followed by `test(\`${row.id}`, which {@link DISPATCH_DISCLOSURE_ROW_LOOP} matches. */ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> = { @@ -74,7 +71,7 @@ export function parseDispatchDisclosureTable(tableText: string): DispatchDisclos return JSON.parse(tableText) as DispatchDisclosureRow[]; } -/** The rows the given driver file owns, less those another branch ships. */ +/** The rows the given driver file owns. */ export function dispatchDisclosureRowsOwnedBy( driverFileUrl: string, tableText: string, @@ -84,8 +81,7 @@ export function dispatchDisclosureRowsOwnedBy( .split(path.sep) .join('/'); return parseDispatchDisclosureTable(tableText).filter( - (row) => - row.implementedBy === undefined && dispatchDisclosureDriverOwner(row.id) === driverFile, + (row) => dispatchDisclosureDriverOwner(row.id) === driverFile, ); } diff --git a/packages/contracts/src/dispatch-disclosure.test.ts b/packages/contracts/src/dispatch-disclosure.test.ts index 22f768c380..800ef07ac1 100644 --- a/packages/contracts/src/dispatch-disclosure.test.ts +++ b/packages/contracts/src/dispatch-disclosure.test.ts @@ -41,16 +41,14 @@ test('dispatch-disclosure rows are unique and use the declared vocabulary', () = assert.ok(DISPATCH_VALUES.includes(row.dispatched), row.id); assert.ok(row.trigger.trim().length > 0, row.id); assert.equal(row.fallsBack !== undefined, row.id.startsWith('maestro-direct.'), row.id); - if (row.implementedBy !== undefined) assert.ok(row.id.startsWith('webdriver.'), row.id); } }); -test('every row without implementedBy has a driver file, and every owner prefix owns a row', () => { +test('every row has a driver file, and every owner prefix owns a row', () => { const rows = parseDispatchDisclosureTable( fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), ); const unowned = rows - .filter((row) => row.implementedBy === undefined) .filter((row) => dispatchDisclosureDriverOwner(row.id) === undefined) .map((row) => row.id); assert.deepEqual(unowned, []); From 590879517b2494f243e78b0eeedbfa2e0c541e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 01:26:52 +0200 Subject: [PATCH 30/42] fix(batch): report unknown after an executed mutating step --- .../0011-interaction-guarantee-contract.md | 13 ++-- packages/command-registry/src/batch.ts | 72 ++++++++++++------- src/commands/batch/public.test.ts | 40 +++++++++++ 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index f1384ba4d1..4f91843195 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -163,7 +163,10 @@ Each request owns one dispatch ledger. Every bound runtime operation declares its effect once, in `RUNTIME_OPERATION_EFFECTS`, a record over the runtime operation keys, so an operation cannot bind without one. The request binding records each `mutates` operation in the request's ledger when its send -returns, so no route sends a mutation outside the ledger. A nested request (a +returns, so no bound runtime operation sends a mutation outside the ledger. +Android ANR and blocking-dialog recovery is outside the ledger: its +`AndroidObservationAdapter.tap` and `openApp` calls reach the device without a +record. A nested request (a batch step, a replay action, a delegated `find` click or fill) records into a ledger of its own and moves its sends into its parent's. @@ -192,9 +195,11 @@ producer. Remaining gaps: a failure before the router's locked scope (session resolution, lock acquisition, lease and daemon-policy admission) never reaches the disclosure and carries no `dispatched`. It sends nothing, but a consumer -must read the absent field as `unknown`. The public `agent-device/batch` -`runBatch` has no ledger when a caller supplies its own `invoke`, so a batch run -outside the daemon keeps the failing step's value. +must read the absent field as `unknown`. + +`runBatch` reports `no` only when none of its executed steps is a +`'mutates-app'` command; after one, the batch failure is `unknown` for every +caller, including one that supplies its own `invoke`. For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of diff --git a/packages/command-registry/src/batch.ts b/packages/command-registry/src/batch.ts index faa7ff4c25..f3da3f5967 100644 --- a/packages/command-registry/src/batch.ts +++ b/packages/command-registry/src/batch.ts @@ -21,6 +21,7 @@ import { assertBatchRuntimeCommandAllowed, normalizeBatchCommandName, } from './batch-policy.ts'; +import { resolveCommandRecordingEffect } from './registry.ts'; const batchAllowedStepKeys = new Set(BATCH_DAEMON_STEP_KEYS); @@ -115,31 +116,11 @@ export async function runBatch( })), }); if (!stepResponse.ok) { - return { - ok: false, - error: { - code: stepResponse.error.code, - message: `Batch failed at step ${stepResponse.step} (${step.command}): ${stepResponse.error.message}`, - hint: stepResponse.error.hint, - diagnosticId: stepResponse.error.diagnosticId, - logPath: stepResponse.error.logPath, - ...(stepResponse.error.retriable === undefined - ? {} - : { retriable: stepResponse.error.retriable }), - ...(stepResponse.error.supportedOn === undefined - ? {} - : { supportedOn: stepResponse.error.supportedOn }), - details: { - ...(stepResponse.error.details ?? {}), - step: stepResponse.step, - command: step.command, - positionals: step.positionals, - executed: index, - total: steps.length, - partialResults, - }, - }, - }; + return batchStepFailure(stepResponse, step, { + executedSteps: steps.slice(0, index), + total: steps.length, + partialResults, + }); } partialResults.push(stepResponse.result); } @@ -159,6 +140,47 @@ export async function runBatch( } } +type BatchStepFailure = Extract>, { ok: false }>; + +function batchStepFailure( + stepResponse: BatchStepFailure, + step: NormalizedBatchStep, + progress: { + executedSteps: readonly NormalizedBatchStep[]; + total: number; + partialResults: BatchStepResult[]; + }, +): Extract { + const { error } = stepResponse; + return { + ok: false, + error: { + code: error.code, + message: `Batch failed at step ${stepResponse.step} (${step.command}): ${error.message}`, + hint: error.hint, + diagnosticId: error.diagnosticId, + logPath: error.logPath, + ...(error.retriable === undefined ? {} : { retriable: error.retriable }), + ...(error.supportedOn === undefined ? {} : { supportedOn: error.supportedOn }), + details: { + ...(error.details ?? {}), + ...(progress.executedSteps.some(stepMutatesApp) ? { dispatched: 'unknown' } : {}), + step: stepResponse.step, + command: step.command, + positionals: step.positionals, + executed: progress.executedSteps.length, + total: progress.total, + partialResults: progress.partialResults, + }, + }, + }; +} + +/** A batch that executed a mutating step cannot promise that a resend repeats nothing. */ +function stepMutatesApp(step: NormalizedBatchStep): boolean { + return resolveCommandRecordingEffect(step) === 'mutates-app'; +} + export function validateAndNormalizeBatchSteps( steps: unknown, maxSteps: number, diff --git a/src/commands/batch/public.test.ts b/src/commands/batch/public.test.ts index 2ee92b79ce..8f5328a89c 100644 --- a/src/commands/batch/public.test.ts +++ b/src/commands/batch/public.test.ts @@ -39,3 +39,43 @@ test('public batch entrypoint exports daemon-compatible orchestration helpers', assert.equal(response.data.results[0]?.command, 'open'); } }); + +async function runBatchFailingAtSecondStep(firstCommand: string, firstPositionals: string[]) { + const req: Omit = { + command: 'batch', + positionals: [], + flags: { + batchSteps: [ + { command: firstCommand, positionals: firstPositionals }, + { command: 'press', positionals: ['label=Missing'] }, + ], + }, + }; + return await runBatch(req, 'session', async (stepReq) => + stepReq.positionals?.[0] === 'label=Missing' + ? { + ok: false, + error: { + code: 'ELEMENT_NOT_FOUND', + message: 'No element matches label=Missing', + details: { dispatched: 'no' }, + }, + } + : { ok: true, data: {} }, + ); +} + +test('batch reports unknown when a mutating step executed before the refused step', async () => { + const response = await runBatchFailingAtSecondStep('press', ['label=A']); + assert.equal(response.ok, false); + if (!response.ok) { + assert.equal(response.error.details?.dispatched, 'unknown'); + assert.equal(response.error.details?.executed, 1); + } +}); + +test('batch keeps the refused step verdict when only reads executed before it', async () => { + const response = await runBatchFailingAtSecondStep('get', ['text', 'label=A']); + assert.equal(response.ok, false); + if (!response.ok) assert.equal(response.error.details?.dispatched, 'no'); +}); From af77d3ce274a7b8e61a2586f832b6fb264420b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 08:20:44 +0200 Subject: [PATCH 31/42] fix(interaction): readiness refusals disclose dispatched no The readiness wait now refuses before any touch on main: a sparse capture (capture_sparse) stamps no where it is thrown, and an exhausted budget inherits no from the selector failure it decorates. A daemon table row drives the exhausted wait through the real readiness path. --- contracts/fixtures/dispatch-disclosure.json | 6 +++ .../interaction/runtime/selector-readiness.ts | 19 ++++---- .../interaction-dispatch-disclosure.test.ts | 46 +++++++++++++++++-- .../press-target-readiness.test.ts | 1 + 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 957f71829a..8150f7a71a 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -17,6 +17,12 @@ "trigger": "fill admission (admitFill) refused the request before any backend call, e.g. a target with no text", "dispatched": "no" }, + { + "id": "daemon.refusal.selector-readiness-exhausted", + "producer": "daemon", + "trigger": "press with readinessTimeoutMs whose selector target never appears: the readiness wait (pollForSelectorReadiness) spends its budget over two or more polls and refuses with selector_not_found before any backend touch", + "dispatched": "no" + }, { "id": "daemon.unclassified", "producer": "daemon", diff --git a/src/commands/interaction/runtime/selector-readiness.ts b/src/commands/interaction/runtime/selector-readiness.ts index 61952fc1cf..66bd47d1c6 100644 --- a/src/commands/interaction/runtime/selector-readiness.ts +++ b/src/commands/interaction/runtime/selector-readiness.ts @@ -178,14 +178,17 @@ async function pollSelectorReadinessOnce( if (attempt.resolved?.node.rect) return attempt; const quality = attempt.capture.snapshot.snapshotQuality; if (isSparseSnapshotQualityVerdict(quality)) { - throw new AppError( - 'COMMAND_FAILED', - `Selector ${selectorExpression} was not found in a sparse capture; the tree cannot prove it absent`, - { - reason: INTERACTION_ERROR_REASONS.captureSparse, - snapshotQuality: quality, - hint: 'Re-run after the screen settles, or capture a snapshot to inspect the tree.', - }, + throw discloseDispatch( + new AppError( + 'COMMAND_FAILED', + `Selector ${selectorExpression} was not found in a sparse capture; the tree cannot prove it absent`, + { + reason: INTERACTION_ERROR_REASONS.captureSparse, + snapshotQuality: quality, + hint: 'Re-run after the screen settles, or capture a snapshot to inspect the tree.', + }, + ), + 'no', ); } const covered = await detectCoveredSelectorTarget({ diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 8dd2d41955..70092d2dff 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -63,9 +63,14 @@ async function routeInteraction(params: InteractionRouteInput) { type PressScenario = { command?: 'press' | 'get' | 'fill'; positionals: string[]; + flags?: Record; }; -async function press({ command = 'press', positionals }: PressScenario): Promise { +async function press({ + command = 'press', + positionals, + flags = {}, +}: PressScenario): Promise { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure'); session.snapshot = { @@ -84,7 +89,7 @@ async function press({ command = 'press', positionals }: PressScenario): Promise }; sessionStore.set(session.name, session); const response = await routeInteraction({ - req: { token: 't', session: session.name, command, positionals, flags: {} }, + req: { token: 't', session: session.name, command, positionals, flags }, sessionName: session.name, sessionStore, contextFromFlags, @@ -94,9 +99,12 @@ async function press({ command = 'press', positionals }: PressScenario): Promise throw new AppError(response.error.code, response.error.message, response.error.details); } -async function refusedPress(positionals: string[]): Promise { +async function refusedPress( + positionals: string[], + flags?: Record, +): Promise { try { - return await press({ positionals }); + return await press({ positionals, flags }); } finally { assert.equal(mockTapPoint.mock.calls.length, 0, 'a refusal must not reach the device'); } @@ -110,6 +118,35 @@ async function refusedFill(positionals: string[]): Promise { } } +/** A press whose readiness wait polls a tree that never lists the selector's target. */ +async function pressWhoseTargetNeverAppears(): Promise { + const capture = vi.mocked(captureSnapshotWithInteractor); + capture.mockResolvedValue({ + nodes: [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + ], + backend: 'xctest', + producer: 'apple-runner', + }); + try { + await refusedPress(['label="Missing"'], { readinessTimeoutMs: 300 }); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'selector_not_found'); + const readiness = error.details?.readiness as { polls: number } | undefined; + assert.ok(readiness && readiness.polls >= 2, `expected >=2 polls, got ${readiness?.polls}`); + throw error; + } finally { + capture.mockReset(); + } + assert.fail('expected the press to be refused'); +} + async function pressAfterUnclassifiedTouchFailure( details?: Record, ): Promise { @@ -207,6 +244,7 @@ const DRIVERS: Record Promise> = { 'daemon.refusal.ref-not-found': () => refusedPress(['@e9']), 'daemon.refusal.admission': () => refusedPress([]), 'daemon.refusal.fill-admission': () => refusedFill(['@e1']), + 'daemon.refusal.selector-readiness-exhausted': pressWhoseTargetNeverAppears, 'daemon.unclassified': () => pressAfterUnclassifiedTouchFailure(), 'daemon.series.swipe-later-repetition-refused': swipeRefusedOnSecondRepetition, 'daemon.post-dispatch.press-then-foreground-read-refused': pressThenForegroundReadRefused, diff --git a/test/integration/provider-scenarios/press-target-readiness.test.ts b/test/integration/provider-scenarios/press-target-readiness.test.ts index a9b0b16e9c..4fb0d34aff 100644 --- a/test/integration/provider-scenarios/press-target-readiness.test.ts +++ b/test/integration/provider-scenarios/press-target-readiness.test.ts @@ -266,6 +266,7 @@ test('press ends the wait at once on a sparse capture with capture_sparse, and n readiness: { polls: number; end: string }; }; assert.equal(details.reason, 'capture_sparse'); + assert.equal((error.details as { dispatched?: unknown }).dispatched, 'no'); assert.equal(details.snapshotQuality.state, 'sparse'); assert.equal(details.readiness.end, 'sparse'); assert.ok(Date.now() - startedAt < 1_500, 'a sparse capture must not burn the budget'); From 8ac2b1d9d9b332365f101cd1056d1a56d1e258ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 08:31:49 +0200 Subject: [PATCH 32/42] fix(daemon): scroll loops leave counting bound sends to the request ledger Each scroll --until and edge pass runs the bound scrollDirection, which the request ledger already records, and the router adds the ledger to any producer count. The two loops counted the same sends again, so two passes reported four. Only a producer counting sub-steps inside one bound operation keeps its own counter. Two router rows pin exactly 2 after a refused third pass. --- contracts/fixtures/dispatch-disclosure.json | 12 +++++ .../src/snapshot/scroll-edge-state.ts | 46 ++++++++--------- .../request-dispatch-disclosure.test.ts | 49 +++++++++++++++++++ src/daemon/scroll-until.ts | 8 +-- 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 8150f7a71a..81564e2a58 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -65,6 +65,18 @@ "trigger": "scroll --until whose first capture is refused before any gesture was sent; scroll-until proves nothing was sent", "dispatched": "no" }, + { + "id": "daemon.route.scroll-until-third-pass-refused", + "producer": "daemon", + "trigger": "scroll --until whose first two gestures returned and whose third is refused with a producer verdict of no; the request ledger counts each bound send once, so details.dispatchedSteps is exactly 2", + "dispatched": "unknown" + }, + { + "id": "daemon.route.scroll-edge-third-pass-refused", + "producer": "daemon", + "trigger": "scroll bottom whose first two gestures returned and whose third is refused with a producer verdict of no; the request ledger counts each bound send once, so details.dispatchedSteps is exactly 2", + "dispatched": "unknown" + }, { "id": "daemon.route.scroll-then-dialog-read-refused", "producer": "daemon", diff --git a/packages/capture-kit/src/snapshot/scroll-edge-state.ts b/packages/capture-kit/src/snapshot/scroll-edge-state.ts index 3630b99d2b..240c1237f6 100644 --- a/packages/capture-kit/src/snapshot/scroll-edge-state.ts +++ b/packages/capture-kit/src/snapshot/scroll-edge-state.ts @@ -1,4 +1,4 @@ -import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; +import { AppError } from '@agent-device/kernel/errors'; import type { ScrollMovementObservation } from '@agent-device/contracts/scroll-command'; import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; import type { Point, RawSnapshotNode, Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; @@ -165,33 +165,29 @@ export async function runScrollEdgePasses(params: { let result: TResult | undefined; const recentSignatures: string[] = []; pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW); - try { - while (state.canScroll) { - if (passes >= SCROLL_EDGE_PASS_LIMIT) { - throw new AppError( - 'COMMAND_FAILED', - `scroll ${edge} reached the safety limit before the snapshot showed the edge`, - { - reason: 'scroll_edge_pass_limit', - edge, - passes, - hint: 'The scoped scroll container still reports hidden content. Run scroll --until to stop on the element you are after, or snapshot -i to inspect the current state.', - }, - ); - } + while (state.canScroll) { + if (passes >= SCROLL_EDGE_PASS_LIMIT) { + throw new AppError( + 'COMMAND_FAILED', + `scroll ${edge} reached the safety limit before the snapshot showed the edge`, + { + reason: 'scroll_edge_pass_limit', + edge, + passes, + hint: 'The scoped scroll container still reports hidden content. Run scroll --until to stop on the element you are after, or snapshot -i to inspect the current state.', + }, + ); + } - result = await scroll(); - passes += 1; - await settleAfterPass(); - state = await captureState(state.scope); + result = await scroll(); + passes += 1; + await settleAfterPass(); + state = await captureState(state.scope); - pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW); - if (state.canScroll && scrollSurfaceIsStuck(recentSignatures)) { - throw buildScrollEdgeNoProgressError(edge, passes); - } + pushScrollSurfaceSignature(recentSignatures, state.fingerprint, SCROLL_EDGE_STUCK_WINDOW); + if (state.canScroll && scrollSurfaceIsStuck(recentSignatures)) { + throw buildScrollEdgeNoProgressError(edge, passes); } - } catch (error) { - throw discloseDispatchAfterSteps(error, passes); } return { passes, result }; diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts index e6c0b6484d..34f645ab94 100644 --- a/src/daemon/__tests__/request-dispatch-disclosure.test.ts +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -159,6 +159,52 @@ async function scrollUntilRefusedBeforeFirstGesture(): Promise { } } +/** A list whose `Email` row stays below the fold while each pass moves the content up. */ +function belowTheFoldList(offset: number): unknown[] { + return [ + { + index: 0, + type: 'XCUIElementTypeScrollView', + label: 'Form', + hiddenContentBelow: true, + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + { + index: 1, + parentIndex: 0, + type: 'XCUIElementTypeTextField', + label: 'Email', + rect: { x: 0, y: 4000 - offset, width: 400, height: 40 }, + }, + ]; +} + +/** Refuses the third gesture after two returned; the content moves only when a gesture returns. */ +async function scrollRefusedOnThirdPass(flags: DaemonRequest['flags'], positionals: string[]) { + gestureRuntimeSpies.captureSnapshot.mockImplementation(async () => { + const moved = gestureRuntimeSpies.scrollDirection.mock.calls.length; + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: belowTheFoldList(moved * 100), + } as never; + }); + gestureRuntimeSpies.scrollDirection + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(RUNNER_BUSY()); + const failure = await route(makeSession(SESSION), { + command: 'scroll', + positionals, + flags, + }).catch((error: unknown) => error); + assert.equal(gestureRuntimeSpies.scrollDirection.mock.calls.length, 3); + assert.ok(failure instanceof AppError); + assert.equal(failure.message, 'runner busy'); + assert.equal(failure.details?.dispatchedSteps, 2); + throw failure; +} + async function androidScrollThenDialogReadRefused(): Promise { const observation: AndroidObservationAdapter = { ...clearAndroidObservationFixture, @@ -282,6 +328,9 @@ const DRIVERS: Record Promise> = { 'daemon.route.batch-read-then-refused-step': batchReadThenRefusedStep, 'daemon.route.scroll-transport-failure': scrollWhoseGestureSendFailed, 'daemon.route.scroll-until-before-first-gesture': scrollUntilRefusedBeforeFirstGesture, + 'daemon.route.scroll-until-third-pass-refused': () => + scrollRefusedOnThirdPass({ until: 'label=Email' }, ['down']), + 'daemon.route.scroll-edge-third-pass-refused': () => scrollRefusedOnThirdPass({}, ['bottom']), 'daemon.route.scroll-then-dialog-read-refused': androidScrollThenDialogReadRefused, 'daemon.route.read-only-get': readOnlyGet, }; diff --git a/src/daemon/scroll-until.ts b/src/daemon/scroll-until.ts index cf4b6f4376..b33d6a1e27 100644 --- a/src/daemon/scroll-until.ts +++ b/src/daemon/scroll-until.ts @@ -1,10 +1,6 @@ import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; -import { - AppError, - discloseDispatch, - discloseDispatchAfterSteps, -} from '@agent-device/kernel/errors'; +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import type { Platform, PublicPlatform } from '@agent-device/kernel/device'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; @@ -100,7 +96,7 @@ export async function runScrollUntilVisible(params: { if (passes === 0 && !scrolling && error instanceof AppError) { throw discloseDispatch(error, 'no'); } - throw discloseDispatchAfterSteps(error, passes); + throw error; } } From 3c689d45c1b98d24b18294d39581280b75365a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 08:32:29 +0200 Subject: [PATCH 33/42] refactor(errors): one dispatched-steps rule, applied to details discloseDispatchAfterSteps now wraps detailsAfterDispatchedSteps instead of restating its rule. withoutLedgerSteps stays: a nested request's own router pass adds its ledger to the failure, which the parent then adds again through its own ledger. --- packages/kernel/src/errors.ts | 37 +++++++++++++---------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 1aecf26cef..747541f4e5 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -566,41 +566,32 @@ export function discloseDispatch( } /** - * The failure of a series that issues more than one device-reaching step, after `dispatchedSteps` - * of them reached the device. `no` describes the whole requested operation, so it holds only while - * no step was dispatched; after that the failure is `unknown`, and `details.dispatchedSteps` adds - * this series' count to any count the failing step already carries. A failure that is not an - * {@link AppError} passes through unchanged for the boundary that normalizes it. - */ -export function discloseDispatchAfterSteps(error: unknown, dispatchedSteps: number): unknown { - if (dispatchedSteps === 0 || !(error instanceof AppError)) return error; - return discloseDispatch(error, 'unknown', { - dispatchedSteps: stepsIncludingFailingStep(error.details, dispatchedSteps), - }); -} - -/** - * {@link discloseDispatchAfterSteps} for a failure already on the wire: the details a failed - * response carries after `dispatchedSteps` device-reaching steps of its request returned. + * The details of a failure after `dispatchedSteps` device-reaching steps of its operation returned. + * `no` describes the whole requested operation, so it holds only while no step was dispatched; after + * that the failure is `unknown`, and `details.dispatchedSteps` adds this count to any count the + * failing step already carries. */ export function detailsAfterDispatchedSteps( details: ErrorWireDetails | undefined, dispatchedSteps: number, ): ErrorWireDetails | undefined { if (dispatchedSteps === 0) return details; + const innerSteps = details?.dispatchedSteps; return { ...details, - dispatchedSteps: stepsIncludingFailingStep(details, dispatchedSteps), + dispatchedSteps: dispatchedSteps + (typeof innerSteps === 'number' ? innerSteps : 0), dispatched: 'unknown', }; } -function stepsIncludingFailingStep( - details: Record | undefined, - dispatchedSteps: number, -): number { - const innerSteps = details?.dispatchedSteps; - return dispatchedSteps + (typeof innerSteps === 'number' ? innerSteps : 0); +/** + * {@link detailsAfterDispatchedSteps} on a thrown failure. A failure that is not an + * {@link AppError} passes through unchanged for the boundary that normalizes it. + */ +export function discloseDispatchAfterSteps(error: unknown, dispatchedSteps: number): unknown { + if (!(error instanceof AppError)) return error; + error.details = detailsAfterDispatchedSteps(error.details, dispatchedSteps); + return error; } /** The side-effect seam's verdict, recorded only when no producer classified the failure. */ From 84ecb894209dde2e3850a8c7a860c9ef562308a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 08:34:22 +0200 Subject: [PATCH 34/42] refactor(daemon): unexport two types only their own modules read --- src/daemon/request-execution-scope.ts | 2 +- src/daemon/runtime-operation-effects.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index bfcf982189..ab0edb01e0 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -125,7 +125,7 @@ export type LockedRequestScope = { ): DaemonCommandContext; }; -export type LockedRequestScopeResult = +type LockedRequestScopeResult = | { type: 'scope'; scope: LockedRequestScope } | { type: 'response'; response: DaemonResponse }; diff --git a/src/daemon/runtime-operation-effects.ts b/src/daemon/runtime-operation-effects.ts index d62f8635e8..9babbf842c 100644 --- a/src/daemon/runtime-operation-effects.ts +++ b/src/daemon/runtime-operation-effects.ts @@ -7,7 +7,7 @@ import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform * app-visible action — reads, idempotent readiness and preparation, and host-side recorder, log, * probe, and profiler controls whose repeat the device refuses or ignores. */ -export type RuntimeOperationEffect = 'mutates' | 'repeatable'; +type RuntimeOperationEffect = 'mutates' | 'repeatable'; /** Every bound operation's effect; the request binding records each `mutates` send it returns. */ export const RUNTIME_OPERATION_EFFECTS: Readonly< From 1055f7e9e032c3035b74501defec07792203c947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 09:01:19 +0200 Subject: [PATCH 35/42] fix(batch): a batch reports no only when every executed step is a declared read --- packages/command-registry/src/batch.ts | 8 ++++---- src/commands/batch/public.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/command-registry/src/batch.ts b/packages/command-registry/src/batch.ts index f3da3f5967..a21ebed8c8 100644 --- a/packages/command-registry/src/batch.ts +++ b/packages/command-registry/src/batch.ts @@ -164,7 +164,7 @@ function batchStepFailure( ...(error.supportedOn === undefined ? {} : { supportedOn: error.supportedOn }), details: { ...(error.details ?? {}), - ...(progress.executedSteps.some(stepMutatesApp) ? { dispatched: 'unknown' } : {}), + ...(progress.executedSteps.some(stepMayMutateApp) ? { dispatched: 'unknown' } : {}), step: stepResponse.step, command: step.command, positionals: step.positionals, @@ -176,9 +176,9 @@ function batchStepFailure( }; } -/** A batch that executed a mutating step cannot promise that a resend repeats nothing. */ -function stepMutatesApp(step: NormalizedBatchStep): boolean { - return resolveCommandRecordingEffect(step) === 'mutates-app'; +/** A batch reports no only when every executed step is a declared read. */ +function stepMayMutateApp(step: NormalizedBatchStep): boolean { + return resolveCommandRecordingEffect(step) !== 'observes-app'; } export function validateAndNormalizeBatchSteps( diff --git a/src/commands/batch/public.test.ts b/src/commands/batch/public.test.ts index 8f5328a89c..ae830ba6e0 100644 --- a/src/commands/batch/public.test.ts +++ b/src/commands/batch/public.test.ts @@ -79,3 +79,9 @@ test('batch keeps the refused step verdict when only reads executed before it', assert.equal(response.ok, false); if (!response.ok) assert.equal(response.error.details?.dispatched, 'no'); }); + +test('batch reports unknown when a step with no declared effect executed before the refused step', async () => { + const response = await runBatchFailingAtSecondStep('test', ['flow.ad']); + assert.equal(response.ok, false); + if (!response.ok) assert.equal(response.error.details?.dispatched, 'unknown'); +}); From b9888962b6dc59474e33ef1a36758fc778c04209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 10:07:53 +0200 Subject: [PATCH 36/42] docs(adr): state the batch dispatched rule as observes-app reads only, pin undeclared step --- docs/adr/0011-interaction-guarantee-contract.md | 7 ++++--- src/commands/batch/public.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 4f91843195..6d9572978f 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -197,9 +197,10 @@ resolution, lock acquisition, lease and daemon-policy admission) never reaches the disclosure and carries no `dispatched`. It sends nothing, but a consumer must read the absent field as `unknown`. -`runBatch` reports `no` only when none of its executed steps is a -`'mutates-app'` command; after one, the batch failure is `unknown` for every -caller, including one that supplies its own `invoke`. +`runBatch` reports `no` only when every executed step is a command declared +`observes-app` (a read); any other executed step, including one with no +declared `recordingEffect`, makes the batch failure `unknown` for every caller, +including one that supplies its own `invoke`. For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of diff --git a/src/commands/batch/public.test.ts b/src/commands/batch/public.test.ts index ae830ba6e0..ef22badb94 100644 --- a/src/commands/batch/public.test.ts +++ b/src/commands/batch/public.test.ts @@ -85,3 +85,9 @@ test('batch reports unknown when a step with no declared effect executed before assert.equal(response.ok, false); if (!response.ok) assert.equal(response.error.details?.dispatched, 'unknown'); }); + +test('batch reports unknown when an undeclared read-only step executed before the refused step', async () => { + const response = await runBatchFailingAtSecondStep('devices', []); + assert.equal(response.ok, false); + if (!response.ok) assert.equal(response.error.details?.dispatched, 'unknown'); +}); From 5e520b5e893322a90dc54143ae1b10d7dad887c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 11:04:37 +0200 Subject: [PATCH 37/42] fix(daemon): delete the no-change interaction retry path (#3083) * fix(daemon)!: delete the no-change interaction retry path The daemon re-sent a coordinate tap when the next capture found the surface unchanged, if the request carried the interactionOutcome retry-on-no-change opt-in. Nothing in the repository set it, and the resend could not know whether the first tap was dispatched, so a tap that landed on a screen that had not re-rendered yet ran twice. Removed: - `interactionOutcome` from `CommandFlags`. No CLI token, env var, config key, Node option or MCP field ever carried it; the daemon now has no reader, and the session recorder keeps only declared flags, so a raw key sent by a peer is dropped. - `SessionState.pendingInteractionOutcome` and its type (the R7 owner entry leaves in the chore(gates) commit). - The pending-outcome branch of `resolveDeferredInteractionOutcome`, the `scheduleOutcomeRetry` mark, and every retry helper in interaction-outcome-policy.ts (`markPendingInteractionOutcome`, `retryPendingInteractionOutcome`, `InteractionRetryTap`, `classifyInteractionSurfaceChange`, the settle diagnostics). - interaction-retry-tap.ts and the `inspectFacts`/`bindDevice` params the snapshot capture carried only to build the retry seam. - `retryPositionals`/`scheduleInteractionOutcomeRetry` on `finalizeTouchInteraction`, and `pointPositionals`. Tests: - interaction-ios-tap-outcome.test.ts "a coordinate click the next snapshot finds unchanged is not re-sent": a click on 104,222, then a `snapshot` whose request carries the same runtime bindings the click used and returns the same tree, asserts one `press`. Mutation: restoring the base retry branch with its opt-in gate forced on (base `shouldRetryTouchOnNoChange` returning true) makes it see three presses (verified). - interaction-touch-runtime.test.ts "press @ref taps the resolved point once and records the ref". Mutation: recording the resolved point positionals instead of the request's `@e1` in finalizeTouchInteraction fails it. - interaction-outcome-policy.test.ts: the classifyInteractionSurfaceChange cases now pin areInteractionSurfaceSignaturesStable, which the stabilization loop and scroll movement still use. Mutation: dropping `stateMarkers(node)` from interactionSurfaceSemanticKey fails the checked-only flip case. - snapshot-handler-freshness.test.ts: the annotation-survival case now rides the post-gesture deferred capture. Mutation: returning only `{ snapshot }` from resolvedPostGestureCapture fails it (verified). * refactor(daemon): move the deferred outcome interface into post-gesture-stabilization.ts With the pending-outcome retry gone, deferred-interaction-outcome.ts only marks and resolves post-gesture stabilization (its R7 field) and Android freshness. It moves to src/daemon/post-gesture-stabilization.ts; the exported names stay, so callers change only their import path. No shim is left at the old path. The surviving cases of deferred-interaction-outcome.test.ts move unchanged into post-gesture-stabilization.test.ts, which already tested this module's stabilization loop. The capture-kit and fixture comments name the new owner. No new tests: the move carries its tests unchanged. * docs: drop the no-change retry from the deferred outcome docs CONTEXT.md's "Deferred interaction outcome" no longer lists outcome retry, docs/agents/selector-capture.md states that the daemon never re-sends an interaction to settle its outcome, and ADR 0004/0023 name post-gesture-stabilization.ts where they named the deleted module. * refactor(daemon): drop the unused logPath from the deferred outcome capture `resolveDeferredInteractionOutcome` read `logPath` only to build the runner context of a re-fired tap. With the retry gone nothing reads it, so the parameter leaves the capture interface and its one production caller (`captureSnapshot`). No new tests: removing an unread parameter changes no behavior; the typecheck is the gate that a caller still passing it fails. * chore(gates): retire the pendingInteractionOutcome R7 owner entry SessionState no longer has pendingInteractionOutcome, so its row leaves SESSION_STATE_FIELD_OWNERS, and the postGestureStabilization owner moves to src/daemon/post-gesture-stabilization.ts. R10 measures the merge-base, so the shrink (16 -> 15 writer-owned fields) banks without a baseline edit. * docs(agents): scope the no-resend rule to the daemon's deferred-outcome path * docs(agents): keep the deferred-outcome rule to one line inside the docs budget * refactor(move): keep the deferred outcome interface in deferred-interaction-outcome.ts Reverts 401869a47b. The module still exports markDeferredInteractionOutcome, resolveDeferredInteractionOutcome and DeferredInteractionOutcomeMark, owns Android freshness as well as post-gesture stabilization, and CONTEXT.md keeps "Deferred interaction outcome" as its glossary term. The surviving cases move back into deferred-interaction-outcome.test.ts, ADR 0004/0023, the capture-kit comment and the R7 owner entry name the original path. git diff -M90% --stat: docs/adr/0004-ios-snapshot-backend-strategy.md | 2 +- docs/adr/0023-end-state-hop-trace.md | 4 +- packages/capture-kit/src/post-gesture-stability.ts | 2 +- scripts/layering/session-state.ts | 2 +- .../__tests__/deferred-interaction-outcome.test.ts | 197 +++++++++++++++++++++ src/daemon/__tests__/is-runtime.test.ts | 2 +- .../__tests__/post-gesture-no-effect-claim.test.ts | 4 +- .../post-gesture-stabilization-fixtures.ts | 4 +- .../__tests__/post-gesture-stabilization.test.ts | 192 +------------------- .../__tests__/snapshot-runtime-disclosure.test.ts | 2 +- ...lization.ts => deferred-interaction-outcome.ts} | 0 src/daemon/direct-ios-selector.ts | 2 +- src/daemon/interaction-outcome-policy.ts | 2 +- .../find-target-activation-disclosure.test.ts | 2 +- .../interaction-capture-disclosure.test.ts | 2 +- .../interaction/internal/interaction-runtime.ts | 2 +- src/daemon/interaction/internal/types.ts | 2 +- src/daemon/request-generic-dispatch.ts | 2 +- src/daemon/scroll-movement.ts | 2 +- src/daemon/selector-capture-runtime.ts | 2 +- .../internal/session-open-execution.ts | 2 +- src/daemon/snapshot-capture.ts | 2 +- 22 files changed, 223 insertions(+), 210 deletions(-) * refactor(move): name the surface comparators interaction-surface-signature.ts With the outcome retry gone, interaction-outcome-policy.ts only builds and compares interaction surface signatures. It moves to interaction-surface-signature.ts with its test. stripInternalInteractionFlags moves to deferred-interaction-outcome.ts, which already reads flags.postGestureStabilization, and its test moves with it. Neither path has a fallow baseline entry, so no baseline changes. git diff -M90% --stat: docs/adr/0023-end-state-hop-trace.md | 2 +- packages/capture-kit/src/post-gesture-stability.ts | 2 +- packages/capture-kit/src/snapshot-chrome.ts | 2 +- src/daemon/__tests__/deferred-interaction-outcome.test.ts | 11 +++++++++++ .../__tests__/interaction-surface-baseline-evidence.test.ts | 2 +- ...policy.test.ts => interaction-surface-signature.test.ts} | 13 +------------ src/daemon/__tests__/post-gesture-no-effect-claim.test.ts | 2 +- .../__tests__/post-gesture-stabilization-verdict.test.ts | 2 +- src/daemon/__tests__/post-gesture-stabilization.test.ts | 2 +- src/daemon/deferred-interaction-outcome.ts | 12 ++++++++++-- ...n-outcome-policy.ts => interaction-surface-signature.ts} | 9 --------- src/daemon/interaction/internal/find.ts | 2 +- src/daemon/interaction/internal/interaction-common.ts | 2 +- src/daemon/scroll-movement.ts | 4 ++-- src/daemon/session-state.ts | 2 +- 15 files changed, 34 insertions(+), 35 deletions(-) * docs: state the current capture ordering without the retired retry Drop the no-resend sentence from the deferred-outcome mark comment, the history line from docs/agents/selector-capture.md, and the retry framing from the interaction-touch-runtime test header. * test(daemon): drop tests that pass with or without the retry Neither coordinate-tap test could set the retired opt-in, so one press held on the base branch too. Delete the unchanged-click test; keep the corroborated coordinate tap only for its corroboration assertions. The one captureSnapshot case left in snapshot-handler-capture-retry.test.ts moves to snapshot-capture.test.ts, which mirrors snapshot-capture.ts, without the Apple runner and iOS hint mocks only the retry cases used. * docs(adr): drop the automatic no-change retry from ADR 0014 No automatic no-change retry exists. Maestro retryTapIfNoChange sends an ordinary press that crosses the leaf seam like any other action. * test: drop no-op retry canary, fake timers in stabilization test, widen CommandFlags waiver --- CONTEXT.md | 2 +- docs/adr/0014-session-ref-frame-lifetime.md | 4 +- docs/adr/0023-end-state-hop-trace.md | 2 +- docs/agents/selector-capture.md | 6 +- .../capture-kit/src/post-gesture-stability.ts | 2 +- packages/capture-kit/src/snapshot-chrome.ts | 2 +- packages/contracts/src/command-flags.ts | 3 - scripts/layering/session-state.ts | 1 - .../deferred-interaction-outcome.test.ts | 167 +------- .../__tests__/interaction-retry-tap.test.ts | 130 ------ ...eraction-surface-baseline-evidence.test.ts | 2 +- ... => interaction-surface-signature.test.ts} | 98 +---- .../post-gesture-no-effect-claim.test.ts | 2 +- ...post-gesture-stabilization-verdict.test.ts | 2 +- .../post-gesture-stabilization.test.ts | 2 +- ...on-replay-runtime-maestro-run-flow.test.ts | 4 - src/daemon/__tests__/snapshot-capture.test.ts | 60 ++- src/daemon/deferred-interaction-outcome.ts | 143 +------ .../snapshot-handler-capture-retry.test.ts | 388 ------------------ .../snapshot-handler-freshness.test.ts | 14 +- src/daemon/interaction-retry-tap.ts | 49 --- ...cy.ts => interaction-surface-signature.ts} | 269 +----------- .../internal/__tests__/find.test.ts | 6 +- .../interaction-ios-tap-outcome.test.ts | 37 +- .../interaction-touch-runtime.test.ts | 39 +- src/daemon/interaction/internal/find.ts | 2 +- .../internal/interaction-common.ts | 9 +- .../internal/interaction-touch-direct-ios.ts | 3 - .../internal/interaction-touch-response.ts | 5 - .../internal/interaction-touch-runtime.ts | 15 - src/daemon/scroll-movement.ts | 4 +- src/daemon/session-state.ts | 14 +- src/daemon/snapshot-capture.ts | 11 - src/daemon/snapshot-command-runtime.ts | 53 +-- test/wire-compat/closure-policy.ts | 2 +- 35 files changed, 176 insertions(+), 1376 deletions(-) delete mode 100644 src/daemon/__tests__/interaction-retry-tap.test.ts rename src/daemon/__tests__/{interaction-outcome-policy.test.ts => interaction-surface-signature.test.ts} (83%) delete mode 100644 src/daemon/handlers/__tests__/snapshot-handler-capture-retry.test.ts delete mode 100644 src/daemon/interaction-retry-tap.ts rename src/daemon/{interaction-outcome-policy.ts => interaction-surface-signature.ts} (63%) diff --git a/CONTEXT.md b/CONTEXT.md index 673d36465f..0c36889339 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -182,7 +182,7 @@ The decision that an active ref frame's epoch and issuance scope authorize a ref An optional `~s` suffix carrying the snapshot generation an `@ref` was minted from. **Deferred interaction outcome**: -Post-response state recording whether a mutation still needs outcome retry, stabilization, or +Post-response state recording whether a mutation's next capture still needs stabilization or snapshot freshness recovery. **Settled observation**: diff --git a/docs/adr/0014-session-ref-frame-lifetime.md b/docs/adr/0014-session-ref-frame-lifetime.md index d5d2e98e88..fb2059f088 100644 --- a/docs/adr/0014-session-ref-frame-lifetime.md +++ b/docs/adr/0014-session-ref-frame-lifetime.md @@ -222,9 +222,7 @@ This rule applies to every execution shape: the same idempotent seam before acting, even when invoked from apparent readiness work. If recovery mutates before a requested ref action dispatches, that ref action aborts with `ref_frame_expired`; it cannot continue against the recovered UI. Selector and coordinate actions - may re-resolve and continue under their existing policies; and -- automatic no-change retries assert that the originating action already expired the frame before - any retry coordinate is sent. + may re-resolve and continue under their existing policies. The seam is deliberately inside leaf execution. Expiring at router entry would reject the ref needed by the current command, invalidate on ordinary validation failures, and mishandle multiplexed and diff --git a/docs/adr/0023-end-state-hop-trace.md b/docs/adr/0023-end-state-hop-trace.md index 4af004ea9a..8a95916477 100644 --- a/docs/adr/0023-end-state-hop-trace.md +++ b/docs/adr/0023-end-state-hop-trace.md @@ -127,7 +127,7 @@ only by its named composition module per ADR Decision §1). **Response path (not hops).** 8 files execute only after the platform call: `src/daemon/interaction/internal/interaction-touch-response.ts`, `src/daemon/interaction/internal/interaction-common.ts`, -`src/daemon/interaction-outcome-policy.ts`, `src/daemon/request-finalization.ts`, +`src/daemon/interaction-surface-signature.ts`, `src/daemon/request-finalization.ts`, `src/daemon/session-event-log.ts`, `src/daemon/session-snapshot.ts`, `src/daemon/recording-gestures.ts`, `src/daemon/deferred-interaction-outcome.ts`. diff --git a/docs/agents/selector-capture.md b/docs/agents/selector-capture.md index 6a6411f457..f357c3863f 100644 --- a/docs/agents/selector-capture.md +++ b/docs/agents/selector-capture.md @@ -23,9 +23,9 @@ These are cross-route behavior requirements; their rationale and owning decision or unchanged evidence remains failure. - Android helper reuse is not snapshot-result caching. Freshness is short-lived, action-triggered, and learned only from route-safe complete observations. -- Pending interaction outcome retry precedes stabilization; Android freshness recovery composes - afterward when required. Gesture-like mutations mark stabilization and disable direct iOS - selector shortcuts while it is pending. +- Post-gesture stabilization runs first and Android freshness recovery composes afterward when + required. Gesture-like mutations mark stabilization and disable direct iOS selector shortcuts + while it is pending. - Session snapshot writes go through the shared snapshot mutation boundary. Sparse observations and empty ref-scoped projections do not overwrite stored evidence. - Maestro matching remains snapshot- and policy-owned. Coordinate dispatch uses fresh geometry; an diff --git a/packages/capture-kit/src/post-gesture-stability.ts b/packages/capture-kit/src/post-gesture-stability.ts index 3204a84e7f..9fb7bd3644 100644 --- a/packages/capture-kit/src/post-gesture-stability.ts +++ b/packages/capture-kit/src/post-gesture-stability.ts @@ -9,7 +9,7 @@ import { observeUntil, type ObservationClock, type ObservationSchedule } from '. * imports no cycle owners and no `SessionState`, so it stays outside the R9 * type cycle while the deferred-interaction-outcome owner (which holds the * pending record and the session mutation) stays the one seam callers see. The - * owner supplies the comparators from interaction-outcome-policy; their + * owner supplies the comparators from interaction-surface-signature; their * semantics (subset tolerance, identity keying, discriminating entries) are * documented there. */ diff --git a/packages/capture-kit/src/snapshot-chrome.ts b/packages/capture-kit/src/snapshot-chrome.ts index 0cbf915b62..c747a0c9c7 100644 --- a/packages/capture-kit/src/snapshot-chrome.ts +++ b/packages/capture-kit/src/snapshot-chrome.ts @@ -333,7 +333,7 @@ export function collectSettleChromeRefs( * only excludes nodes whose OWN type is `keyboard` still leaks every key and * assistant control as "discriminating" evidence. * - * Used by `src/daemon/interaction-outcome-policy.ts`'s post-gesture + * Used by `src/daemon/interaction-surface-signature.ts`'s post-gesture * baseline-distrust discriminating-overlap classification (#1542 defect 2, * #1563 review): that comparison operates on flat signature entries with no * ref-selection budget of its own, so it needs the ref set directly rather diff --git a/packages/contracts/src/command-flags.ts b/packages/contracts/src/command-flags.ts index a5ab9ab429..f429b8ed70 100644 --- a/packages/contracts/src/command-flags.ts +++ b/packages/contracts/src/command-flags.ts @@ -28,9 +28,6 @@ export type MaestroRuntimeFlags = { export type CommandFlags = Omit & { batchSteps?: DaemonBatchStep[]; clearAppState?: boolean; - interactionOutcome?: { - retryOnNoChange?: boolean; - }; launchArgs?: string[]; kind?: string; maestro?: MaestroRuntimeFlags; diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index 6b21c290f2..b13653694f 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -87,7 +87,6 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly(async () => true); - afterEach(() => { vi.useRealTimers(); - retryTap.mockClear(); }); function scriptedCapture(snapshots: ReturnType[]): { @@ -51,16 +41,14 @@ function resolveParams( return { session, device: session.device, - logPath: '/tmp/agent-device-test.log', interactiveOnly: false, capture, - retryTap, }; } // --- mutation-side marking --- -test('marking is one call for all three flags, each keeping its own eligibility gate', () => { +test('marking is one call for both flags, each keeping its own eligibility gate', () => { const session = makeSession('android'); session.snapshot = pickupSnapshot(); @@ -68,17 +56,15 @@ test('marking is one call for all three flags, each keeping its own eligibility session, command: 'click', positionals: ['100', '200'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - scheduleOutcomeRetry: true, + flags: undefined, }); - assert.equal(session.pendingInteractionOutcome?.command, 'press'); assert.equal(session.androidSnapshotFreshness?.action, 'click'); // click is not a stabilizing gesture — its gate declines independently. assert.equal(isPostGestureStabilizationPending(session), false); }); -test('a scroll marks stabilization but neither retry nor freshness', () => { +test('a scroll marks stabilization but not freshness', () => { const session = makeSession('android'); session.snapshot = pickupSnapshot(); @@ -90,24 +76,9 @@ test('a scroll marks stabilization but neither retry nor freshness', () => { }); assert.equal(isPostGestureStabilizationPending(session), true); - assert.equal(session.pendingInteractionOutcome, undefined); assert.equal(session.androidSnapshotFreshness, undefined); }); -test('outcome retry is never scheduled unless the caller asks', () => { - const session = makeSession('ios'); - session.snapshot = pickupSnapshot(); - - markDeferredInteractionOutcome({ - session, - command: 'click', - positionals: ['100', '200'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - }); - - assert.equal(session.pendingInteractionOutcome, undefined); -}); - test('freshness and stabilization eligibility read the action, not the outer command', () => { const session = makeSession('android'); session.snapshot = pickupSnapshot(); @@ -136,126 +107,6 @@ test('nothing deferred resolves to undefined without capturing', async () => { assert.equal(calls(), 0); }); -test('an unchanged surface retries the recorded tap once, then settles on the changed capture', async () => { - vi.useFakeTimers(); - const session = makeSession('ios'); - session.snapshot = pickupSnapshot(); - markDeferredInteractionOutcome({ - session, - command: 'click', - positionals: ['100', '200'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - scheduleOutcomeRetry: true, - }); - // capture 1+2: still the pre-action surface (delayed-change recheck fires); - // capture 3: the surface the retried tap produced. - const { capture } = scriptedCapture([pickupSnapshot(), pickupSnapshot(), deliverySnapshot()]); - - const pendingResult = resolveDeferredInteractionOutcome(resolveParams(session, capture)); - for (let step = 0; step < 10; step += 1) { - await vi.advanceTimersByTimeAsync(500); - } - const result = await pendingResult; - - assert.equal(retryTap.mock.calls.length, 1); - assert.deepEqual(retryTap.mock.calls[0]?.[0]?.point, { x: 100, y: 200 }); - assert.equal( - result?.snapshot.nodes.some((node) => node.identifier === 'shipping-delivery'), - true, - ); - assert.equal(session.pendingInteractionOutcome, undefined); -}); - -test('a changed surface settles immediately with no retry dispatch', async () => { - const session = makeSession('ios'); - session.snapshot = pickupSnapshot(); - markDeferredInteractionOutcome({ - session, - command: 'press', - positionals: ['100', '200'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - scheduleOutcomeRetry: true, - }); - const { capture, calls } = scriptedCapture([deliverySnapshot()]); - - const result = await resolveDeferredInteractionOutcome(resolveParams(session, capture)); - - assert.equal(retryTap.mock.calls.length, 0); - assert.equal(calls(), 1); - assert.ok(result?.snapshot); - assert.equal(session.pendingInteractionOutcome, undefined); -}); - -// R58: the policy owns whether to retry; its caller owns how the tap reaches the device. A -// capture route that carries no bindings (an internal capture decorating someone else's request) -// therefore hands no seam, and the retry must report that instead of burning an attempt. -test('a capture route with no retry seam reports a skip instead of spending an attempt', async () => { - const session = makeSession('ios'); - session.snapshot = pickupSnapshot(); - markDeferredInteractionOutcome({ - session, - command: 'click', - positionals: ['100', '200'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - scheduleOutcomeRetry: true, - }); - const { capture } = scriptedCapture([pickupSnapshot()]); - - const observed = await withDiagnosticsScope({}, async () => { - const result = await resolveDeferredInteractionOutcome({ - ...resolveParams(session, capture), - retryTap: undefined, - }); - return { - result, - skips: countDiagnosticEventsByPhase(['interaction_no_change_retry_skipped']), - retries: countDiagnosticEventsByPhase(['interaction_no_change_retry']), - }; - }); - - assert.ok(observed.result?.snapshot); - assert.equal(retryTap.mock.calls.length, 0); - // One skip, no retry: nothing was attempted, so no attempt was spent. The emitted reason - // (`device-runtime-unavailable`) is what separates this from a delivered tap the owner refused — - // a route that stops forwarding its bindings lands here, not there. - assert.equal(observed.skips, 1); - assert.equal(observed.retries, 0); -}); - -// A retry that dies on the device is the seam's problem, not the caller's: the capture it was -// decorating still has to answer with the unchanged surface it observed. -test('a retry tap that throws is reported as a skip rather than failing the capture', async () => { - const session = makeSession('android'); - session.snapshot = pickupSnapshot(); - markDeferredInteractionOutcome({ - session, - command: 'click', - positionals: ['100', '200'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - scheduleOutcomeRetry: true, - }); - const attemptsBefore = session.pendingInteractionOutcome?.attemptsRemaining; - const { capture } = scriptedCapture([pickupSnapshot()]); - retryTap.mockRejectedValueOnce(new Error('adb: device offline')); - - const observed = await withDiagnosticsScope({}, async () => { - const result = await resolveDeferredInteractionOutcome(resolveParams(session, capture)); - return { - result, - skips: countDiagnosticEventsByPhase(['interaction_no_change_retry_skipped']), - retries: countDiagnosticEventsByPhase(['interaction_no_change_retry']), - }; - }); - - assert.ok(observed.result?.snapshot); - assert.equal(observed.skips, 1); - assert.equal(observed.retries, 0); - // The attempt is spent before the device work, so a failing owner cannot be re-attempted from a - // full budget by the next capture inside the pending window. - assert.equal(attemptsBefore, 2); - assert.equal(session.pendingInteractionOutcome, undefined); -}); - test('a pending stabilization resolves through the quiet-window loop and clears itself', async () => { vi.useFakeTimers(); const session = makeSession('android'); @@ -345,3 +196,13 @@ test('android freshness recovery re-captures past a stale dump and clears the wi assert.equal(result?.freshness?.staleAfterRetries, false); assert.equal(session.androidSnapshotFreshness, undefined); }); + +test('stripInternalInteractionFlags removes internal interaction controls', () => { + assert.deepEqual( + stripInternalInteractionFlags({ + platform: 'ios', + postGestureStabilization: true, + }), + { platform: 'ios' }, + ); +}); diff --git a/src/daemon/__tests__/interaction-retry-tap.test.ts b/src/daemon/__tests__/interaction-retry-tap.test.ts deleted file mode 100644 index 543f56bf79..0000000000 --- a/src/daemon/__tests__/interaction-retry-tap.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { - localRuntimeOwner, - narrowDeviceBinding, - type DeviceBinding, -} from '@agent-device/contracts/platform-runtime'; -import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; -import type { TapPointInput } from '@agent-device/contracts/touch-runtime'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { createInteractionRetryTap } from '../interaction-retry-tap.ts'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; - -const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -}; - -/** One owner cell, parametrized on whether it can tap — the only fact this adapter reads. */ -function bindings( - canTap: boolean, - taps: TapPointInput[], -): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime }> { - const facts = async () => { - const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); - return Object.freeze({ - device: base.device, - operations: { - ...base.operations, - tapPoint: canTap - ? ({ available: true } as const) - : ({ available: false, reason: 'owner-capability-missing' } as const), - }, - }); - }; - const inspectFacts: InspectDeviceRuntimeFacts = async () => await facts(); - const bindDevice: BindDeviceRuntime = (async (target: DeviceInfo, use) => { - const binding: DeviceBinding = Object.freeze({ - device: target, - owner: localRuntimeOwner('android'), - facts: await facts(), - operations: Object.freeze( - canTap - ? { - tapPoint: async (input: TapPointInput) => { - taps.push(input); - return {}; - }, - } - : {}, - ), - [Symbol.asyncDispose]: async () => undefined, - }) as DeviceBinding; - return narrowDeviceBinding(binding, use); - }) as BindDeviceRuntime; - return { inspectFacts, bindDevice }; -} - -test('a capture route with no runtime bindings has no retry seam to hand the policy', () => { - assert.equal(createInteractionRetryTap({}), undefined); - assert.equal(createInteractionRetryTap({ inspectFacts: async () => ({}) as never }), undefined); -}); - -test('the seam re-fires the recorded point through the owner-bound tapPoint', async () => { - const taps: TapPointInput[] = []; - const retryTap = createInteractionRetryTap(bindings(true, taps)); - assert.ok(retryTap); - - const fired = await retryTap({ - device, - point: { x: 100, y: 200 }, - context: { logPath: '/tmp/daemon.log', requestId: 'retry-1' }, - }); - - assert.equal(fired, true); - assert.equal(taps.length, 1); - assert.deepEqual(taps[0]?.point, { x: 100, y: 200 }); -}); - -// ADR 0019 §9 is one admission per handler, and the outcome policy calls this seam once per retry -// round. The binding underneath is request-cached, so what memoization saves is the repeated facts -// inspection — and what it pins is that the seam admits once, not once per round. -test('the seam admits once no matter how many rounds the policy runs', async () => { - const taps: TapPointInput[] = []; - const admission = bindings(true, taps); - let inspections = 0; - const retryTap = createInteractionRetryTap({ - inspectFacts: async (target) => { - inspections += 1; - return await admission.inspectFacts(target); - }, - bindDevice: admission.bindDevice, - }); - assert.ok(retryTap); - - const context = { logPath: '/tmp/daemon.log', requestId: 'retry-1' }; - await retryTap({ device, point: { x: 10, y: 20 }, context }); - await retryTap({ device, point: { x: 30, y: 40 }, context }); - - assert.equal(inspections, 1); - assert.equal(taps.length, 2); - assert.deepEqual( - taps.map((tap) => tap.point), - [ - { x: 10, y: 20 }, - { x: 30, y: 40 }, - ], - ); -}); - -// The policy spends an attempt only on a delivered tap, so a cell that cannot tap must answer -// `false` here rather than throwing out of the capture the retry was decorating. -test('an owner cell that cannot tap answers false instead of throwing', async () => { - const taps: TapPointInput[] = []; - const retryTap = createInteractionRetryTap(bindings(false, taps)); - assert.ok(retryTap); - - const fired = await retryTap({ - device, - point: { x: 100, y: 200 }, - context: { logPath: '/tmp/daemon.log' }, - }); - - assert.equal(fired, false); - assert.equal(taps.length, 0); -}); diff --git a/src/daemon/__tests__/interaction-surface-baseline-evidence.test.ts b/src/daemon/__tests__/interaction-surface-baseline-evidence.test.ts index 074be2e2e3..27cb6a1f3f 100644 --- a/src/daemon/__tests__/interaction-surface-baseline-evidence.test.ts +++ b/src/daemon/__tests__/interaction-surface-baseline-evidence.test.ts @@ -4,7 +4,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { buildInteractionSurfaceSignature, classifyBaselineSurfaceEvidence, -} from '../interaction-outcome-policy.ts'; +} from '../interaction-surface-signature.ts'; // #1569: node shapes below are transcribed from a live iPhone 17 Pro capture of // examples/test-app's checkout form, before and after `scroll down 0.6`. Two diff --git a/src/daemon/__tests__/interaction-outcome-policy.test.ts b/src/daemon/__tests__/interaction-surface-signature.test.ts similarity index 83% rename from src/daemon/__tests__/interaction-outcome-policy.test.ts rename to src/daemon/__tests__/interaction-surface-signature.test.ts index 20ef6154b2..bf0d5677b9 100644 --- a/src/daemon/__tests__/interaction-outcome-policy.test.ts +++ b/src/daemon/__tests__/interaction-surface-signature.test.ts @@ -4,40 +4,36 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { buildInteractionSurfaceSignature, classifyBaselineSurfaceEvidence, - classifyInteractionSurfaceChange, + areInteractionSurfaceSignaturesStable, discriminatingSurfaceChangedWithinRect, - markPendingInteractionOutcome, - stripInternalInteractionFlags, -} from '../interaction-outcome-policy.ts'; -import type { SessionState } from '../session-state.ts'; -import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +} from '../interaction-surface-signature.ts'; -test('classifyInteractionSurfaceChange treats identical surfaces as unchanged', () => { +test('areInteractionSurfaceSignaturesStable treats identical surfaces as stable', () => { const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes); const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes); - assert.equal(classifyInteractionSurfaceChange(before, after), 'unchanged'); + assert.equal(areInteractionSurfaceSignaturesStable(before, after), true); }); -test('classifyInteractionSurfaceChange tolerates tiny rect drift', () => { +test('areInteractionSurfaceSignaturesStable tolerates tiny rect drift', () => { const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100).nodes); const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100.4).nodes); - assert.equal(classifyInteractionSurfaceChange(before, after), 'unchanged'); + assert.equal(areInteractionSurfaceSignaturesStable(before, after), true); }); -test('classifyInteractionSurfaceChange detects semantic screen changes', () => { +test('areInteractionSurfaceSignaturesStable detects semantic screen changes', () => { const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes); const after = buildInteractionSurfaceSignature(makeSnapshot('Article detail').nodes); - assert.equal(classifyInteractionSurfaceChange(before, after), 'changed'); + assert.equal(areInteractionSurfaceSignaturesStable(before, after), false); }); -test('classifyInteractionSurfaceChange detects material layout movement', () => { +test('areInteractionSurfaceSignaturesStable detects material layout movement', () => { const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100).nodes); const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 180).nodes); - assert.equal(classifyInteractionSurfaceChange(before, after), 'changed'); + assert.equal(areInteractionSurfaceSignaturesStable(before, after), false); }); // --------------------------------------------------------------------------- @@ -300,72 +296,6 @@ function applicationRootNode() { }; } -test('markPendingInteractionOutcome stores retry state only for explicit retry flags', () => { - const session = makeSession(); - markPendingInteractionOutcome({ - session, - command: 'click', - positionals: ['20', '40'], - flags: {}, - preSnapshot: makeSnapshot('Inbox'), - }); - assert.equal(session.pendingInteractionOutcome, undefined); - - const retrySession = makeSession(); - markPendingInteractionOutcome({ - session: retrySession, - command: 'click', - positionals: ['20', '40'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - preSnapshot: makeSnapshot('Inbox'), - }); - - assert.equal(retrySession.pendingInteractionOutcome?.action, 'click'); - assert.equal(retrySession.pendingInteractionOutcome?.command, 'press'); - assert.equal(retrySession.pendingInteractionOutcome?.attemptsRemaining, 2); - assert.equal(retrySession.pendingInteractionOutcome?.flags?.interactionOutcome, undefined); - - const refSession = makeSession(); - markPendingInteractionOutcome({ - session: refSession, - command: 'click', - positionals: ['@e1'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - preSnapshot: makeSnapshot('Inbox'), - }); - assert.equal(refSession.pendingInteractionOutcome, undefined); - - const longPressSession = makeSession(); - markPendingInteractionOutcome({ - session: longPressSession, - command: 'longpress', - positionals: ['20', '40', '800'], - flags: { interactionOutcome: { retryOnNoChange: true } }, - preSnapshot: makeSnapshot('Inbox'), - }); - assert.equal(longPressSession.pendingInteractionOutcome, undefined); -}); - -test('stripInternalInteractionFlags removes internal interaction controls', () => { - assert.deepEqual( - stripInternalInteractionFlags({ - platform: 'ios', - interactionOutcome: { retryOnNoChange: true }, - postGestureStabilization: true, - }), - { platform: 'ios' }, - ); -}); - -function makeSession(): SessionState { - return { - name: 'ios', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - }; -} - function makeSnapshot(label: string, y = 100): SnapshotState { return { nodes: [ @@ -469,13 +399,13 @@ test('discriminatingSurfaceChangedWithinRect counts content appearing inside the }); // Android is the only producer of `checked`, and a tap whose only effect is a toggle changes nothing -// else on a screen without a mirrored label. The outcome lane has to read the flip as a change, or a -// no-change retry taps the switch straight back. -test('classifyInteractionSurfaceChange reads a checked-only flip as a change', () => { +// else on a screen without a mirrored label. The flip has to read as a change, or a quiet window +// that spans it reports a surface that never moved. +test('areInteractionSurfaceSignaturesStable reads a checked-only flip as a change', () => { const before = buildInteractionSurfaceSignature(makeToggleSnapshot(false).nodes); const after = buildInteractionSurfaceSignature(makeToggleSnapshot(true).nodes); - assert.equal(classifyInteractionSurfaceChange(before, after), 'changed'); + assert.equal(areInteractionSurfaceSignaturesStable(before, after), false); }); test.each([ diff --git a/src/daemon/__tests__/post-gesture-no-effect-claim.test.ts b/src/daemon/__tests__/post-gesture-no-effect-claim.test.ts index d07512cd9e..af3de1168e 100644 --- a/src/daemon/__tests__/post-gesture-no-effect-claim.test.ts +++ b/src/daemon/__tests__/post-gesture-no-effect-claim.test.ts @@ -11,7 +11,7 @@ import { import { buildInteractionSurfaceSignature, summarizeDiscriminatingSurfaceDivergence, -} from '../interaction-outcome-policy.ts'; +} from '../interaction-surface-signature.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import { capturePostGestureStabilizedResult, diff --git a/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts b/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts index 243b5a8589..6a4d684c27 100644 --- a/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization-verdict.test.ts @@ -4,7 +4,7 @@ import { buildInteractionSurfaceSignature, classifyBaselineSurfaceEvidence, type InteractionSurfaceSignature, -} from '../interaction-outcome-policy.ts'; +} from '../interaction-surface-signature.ts'; import { decidePostGestureStabilityVerdict as decideWithHooks } from '@agent-device/capture-kit/post-gesture-stability'; import { applicationRootNode, diff --git a/src/daemon/__tests__/post-gesture-stabilization.test.ts b/src/daemon/__tests__/post-gesture-stabilization.test.ts index 2050c1db25..81cfceca5d 100644 --- a/src/daemon/__tests__/post-gesture-stabilization.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization.test.ts @@ -6,7 +6,7 @@ import { countDiagnosticEventsByPhase, withDiagnosticsScope, } from '@agent-device/host-kit/diagnostics'; -import { buildInteractionSurfaceSignature } from '../interaction-outcome-policy.ts'; +import { buildInteractionSurfaceSignature } from '../interaction-surface-signature.ts'; import { capturePostGestureStabilizedResult, markDeferredInteractionOutcome, diff --git a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-run-flow.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-run-flow.test.ts index 3c0dc28830..0e8ccf64eb 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-run-flow.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-run-flow.test.ts @@ -94,10 +94,6 @@ test('runReplayCommand runs Maestro runFlow.when.visible commands when present', ['click', ['76', '122']], ], ); - assert.equal( - calls.find((call) => call.command === 'click')?.flags?.interactionOutcome, - undefined, - ); assert.equal( calls.find((call) => call.command === 'click')?.flags?.postGestureStabilization, undefined, diff --git a/src/daemon/__tests__/snapshot-capture.test.ts b/src/daemon/__tests__/snapshot-capture.test.ts index 3f24b8729c..3f42870fd5 100644 --- a/src/daemon/__tests__/snapshot-capture.test.ts +++ b/src/daemon/__tests__/snapshot-capture.test.ts @@ -1,11 +1,23 @@ import { expect, test, vi } from 'vitest'; -import { captureSnapshotData } from '../snapshot-capture.ts'; +import { captureSnapshot, captureSnapshotData } from '../snapshot-capture.ts'; import { buildSnapshotVisibility } from '@agent-device/capture-kit/snapshot-visibility'; import { ANDROID_EMULATOR, IOS_SIMULATOR, MACOS_DEVICE, } from '../../__tests__/test-utils/device-fixtures.ts'; +import { buildNodes } from '../../__tests__/test-utils/snapshot-builders.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from './legacy-snapshot-capture-fixture.ts'; +import { + androidCapture, + androidDevice, + androidTextRows, + inboxBaselineNodes, + makeAndroidFreshnessSession, +} from '../handlers/__tests__/snapshot-handler.fixtures.ts'; const captureSnapshotWithInteractor = vi.hoisted(() => vi.fn()); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor })); @@ -94,3 +106,49 @@ test('buildSnapshotVisibility handles nodes with no scroll hints as non-partial' expect(vis.totalNodeCount).toBe(2); expect(vis.reasons).toEqual([]); }); + +test('captureSnapshot composes post-gesture stabilization with Android freshness capture', async () => { + resetLegacySnapshotCapture(captureSnapshotWithInteractor); + const sessionName = 'android-post-gesture-freshness'; + const baselineNodes = inboxBaselineNodes(18); + const changedNodes = buildNodes( + androidTextRows(18, (row) => (row === 1 ? 'album-0' : `Album row ${row}`)), + ); + const session = makeAndroidFreshnessSession(sessionName, 'click', baselineNodes); + session.postGestureStabilization = { + action: 'click', + positionals: [], + markedAt: Date.now(), + }; + + legacyDispatchCapture + .mockResolvedValueOnce(androidCapture(baselineNodes, { rawNodeCount: 18, maxDepth: 1 })) + .mockResolvedValueOnce(androidCapture(changedNodes, { rawNodeCount: 18, maxDepth: 1 })) + .mockResolvedValueOnce(androidCapture(changedNodes, { rawNodeCount: 18, maxDepth: 1 })); + + vi.useFakeTimers(); + let result: Awaited>; + try { + const pending = captureSnapshot({ + device: androidDevice, + session, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/daemon.log', + }); + await vi.advanceTimersByTimeAsync(10_000); + result = await pending; + } finally { + vi.useRealTimers(); + } + + expect(result.snapshot.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'album-0' })]), + ); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual([ + 'snapshot', + 'snapshot', + 'snapshot', + ]); + expect(session.androidSnapshotFreshness).toBeUndefined(); + expect(session.postGestureStabilization).toBeUndefined(); +}); diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index 896b82b4c3..0eec0230cd 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -2,10 +2,8 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { SnapshotCaptureAnnotations } from '@agent-device/contracts/capture'; import { isApplePlatform, isMobilePlatform } from '@agent-device/kernel/device'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; -import { sleep } from '@agent-device/host-kit/retry'; import { captureAndroidFreshnessRecoveredAttempt, - clearAndroidSnapshotFreshness, getActiveAndroidSnapshotFreshness, markAndroidSnapshotFreshness, } from './session-snapshot-freshness.ts'; @@ -17,18 +15,10 @@ import { areInteractionSurfaceSignaturesStable, buildInteractionSurfaceSignature, classifyBaselineSurfaceEvidence, - classifyInteractionSurfaceChange, - clearPendingInteractionOutcome, - emitInteractionSettled, - emitInteractionSettleTimeout, - getActivePendingInteractionOutcome, haveIdenticalDiscriminatingSurfaces, summarizeDiscriminatingSurfaceDivergence, - markPendingInteractionOutcome, - retryPendingInteractionOutcome, snapshotSurfaceComparisonKey, - type InteractionRetryTap, -} from './interaction-outcome-policy.ts'; +} from './interaction-surface-signature.ts'; import { runPostGestureStabilityLoop, type PostGestureStabilityOutcome, @@ -40,7 +30,7 @@ import type { SessionState } from './session-state.ts'; * actually take effect?", produced after the mutation's own response has been * sent. This module is its one interface — every mutating route marks through * `markDeferredInteractionOutcome` right after dispatch, and every snapshot - * capture resolves through `resolveDeferredInteractionOutcome`. The three + * capture resolves through `resolveDeferredInteractionOutcome`. The two * SessionState fields stay with their owner modules; this module is itself the * `postGestureStabilization` owner (R7), so hosting the interface here adds no * new node to the R9 type cycle — the seam lives in a node that was already on @@ -53,14 +43,10 @@ import type { SessionState } from './session-state.ts'; * disciplines and never route through here. */ -const INTERACTION_CHANGE_RECHECK_DELAY_MS = 500; - /** * Mutation-side marking, called once per mutating dispatch after the device op - * returned. Ordering across the markers is load-bearing (Selector Capture - * Reliability Contract: pending interaction outcome retry runs before - * post-gesture stabilization) and each marker keeps its own eligibility gate, - * so callers do not pre-filter — an ineligible action simply marks nothing. + * returned. Each marker keeps its own eligibility gate, so callers do not + * pre-filter — an ineligible action simply marks nothing. */ export type DeferredInteractionOutcomeMark = { command: string; @@ -68,8 +54,6 @@ export type DeferredInteractionOutcomeMark = { action?: string; positionals: string[]; flags: CommandFlags | undefined; - /** True only when no post-action observation already proved the interaction landed. */ - scheduleOutcomeRetry?: boolean; androidFreshnessBaseline?: SnapshotState | undefined; }; @@ -82,18 +66,8 @@ export function markDeferredInteractionOutcome( action = command, positionals, flags, - scheduleOutcomeRetry = false, androidFreshnessBaseline, } = params; - if (scheduleOutcomeRetry) { - markPendingInteractionOutcome({ - session, - command, - positionals, - flags, - preSnapshot: session.snapshot, - }); - } if (isNavigationSensitiveAction(action)) { markAndroidSnapshotFreshness(session, action, androidFreshnessBaseline ?? session.snapshot); } @@ -110,9 +84,8 @@ function markPostGestureStabilization( if (!isPostGestureStabilizingAction(action, positionals, flags)) return; // No extra capture: `session.snapshot` is still whatever was captured // before this gesture dispatched (this call happens post-dispatch, - // pre-capture — the same "last known pre-action snapshot" idiom - // `markPendingInteractionOutcome` already relies on). Like that sibling, an - // empty signature is never stored: "no usable baseline" has exactly one + // pre-capture — the last known pre-action snapshot). An empty signature is + // never stored: "no usable baseline" has exactly one // representation (absent), so no consumer has to tell `undefined` from `[]` // — and the loop cannot rebase a baseline that was never really there. const baselineSignature = requiresPostGestureBaselineDistrust(session.device) @@ -157,9 +130,6 @@ export type DeferredOutcomeSnapshotAttempt = { type DeferredOutcomeCaptureParams = { session: SessionState | undefined; device: SessionState['device']; - logPath: string; - /** How a no-change retry re-fires the recorded tap; absent on captures that cannot tap. */ - retryTap?: InteractionRetryTap; /** Whether the capture the verdict rides on was interactive-only filtered. */ interactiveOnly: boolean; androidFreshnessMode?: SnapshotFreshnessMode; @@ -172,21 +142,14 @@ export type DeferredOutcomeCaptureResult = { /** * Capture-side resolution: when the session carries a deferred outcome, run - * the capture through the machinery that settles it (pending-outcome retry, - * then post-gesture stabilization, then Android freshness recovery) and + * the capture through the machinery that settles it (post-gesture + * stabilization, then Android freshness recovery) and * return the resolved capture. Returns undefined when nothing is deferred — * the caller then captures plainly. */ export async function resolveDeferredInteractionOutcome( params: DeferredOutcomeCaptureParams, ): Promise { - const pendingInteractionOutcome = getActivePendingInteractionOutcome(params.session); - if (pendingInteractionOutcome && params.session) { - return await captureInteractionOutcomeAwareSnapshot( - { ...params, session: params.session }, - pendingInteractionOutcome, - ); - } if ( isMobilePlatform(params.device) && params.session && @@ -205,86 +168,6 @@ export async function resolveDeferredInteractionOutcome( return undefined; } -async function captureInteractionOutcomeAwareSnapshot( - params: DeferredOutcomeCaptureParams & { session: SessionState }, - pending: NonNullable, -): Promise { - const session = params.session; - - const startedAt = Date.now(); - let retryAttempts = 0; - let latest = await waitForDelayedInteractionSurfaceChange( - params, - pending, - await capturePostActionSnapshotAttempt(params), - ); - let outcome = await retryPendingInteractionOutcome({ - session, - pending, - logPath: params.logPath, - snapshot: latest.snapshot, - retryTap: params.retryTap, - }); - - while (outcome.retried) { - retryAttempts += 1; - latest = await waitForDelayedInteractionSurfaceChange( - params, - pending, - await capturePostActionSnapshotAttempt(params), - ); - outcome = await retryPendingInteractionOutcome({ - session, - pending, - logPath: params.logPath, - snapshot: latest.snapshot, - retryTap: params.retryTap, - }); - } - - clearPendingInteractionOutcome(session); - const stabilized = await capturePostGestureStabilizedResult({ - session, - initial: latest, - capture: async () => await capturePostActionSnapshotAttempt(params), - readSnapshot: (attempt) => attempt.snapshot, - }); - latest = stabilized.value; - if (outcome.change !== 'ambiguous' && latest.annotations.freshness?.staleAfterRetries !== true) { - clearAndroidSnapshotFreshness(session); - } - if (outcome.change === 'unchanged') { - emitInteractionSettleTimeout({ pending, attempts: retryAttempts, startedAt }); - } else { - emitInteractionSettled({ - pending, - change: outcome.change, - attempts: retryAttempts, - startedAt, - }); - } - - return resolvedPostGestureCapture(stabilized); -} - -async function waitForDelayedInteractionSurfaceChange( - params: DeferredOutcomeCaptureParams & { session: SessionState }, - pending: NonNullable, - initial: DeferredOutcomeSnapshotAttempt, -): Promise { - let latest = initial; - const change = classifyInteractionSurfaceChange( - pending.preSignature, - buildInteractionSurfaceSignature(latest.snapshot.nodes), - ); - if (change !== 'unchanged') return latest; - - await sleep(INTERACTION_CHANGE_RECHECK_DELAY_MS); - latest = await capturePostActionSnapshotAttempt(params); - - return latest; -} - async function capturePostGestureAwareSnapshot( params: DeferredOutcomeCaptureParams & { session: SessionState }, ): Promise { @@ -309,7 +192,7 @@ async function capturePostActionSnapshotAttempt( /** * Session-aware adapter over the pure stability loop * (`post-gesture-stability.ts`): reads the pending record, supplies the - * interaction-surface comparators from interaction-outcome-policy as hooks, + * interaction-surface comparators from interaction-surface-signature as hooks, * and — as the R7 owner — clears `postGestureStabilization` once the loop * has run, on settle, timeout and an aborted capture alike. */ @@ -367,6 +250,14 @@ function resolvedPostGestureCapture( return { snapshot, ...annotations }; } +export function stripInternalInteractionFlags( + flags: CommandFlags | undefined, +): CommandFlags | undefined { + if (!flags?.postGestureStabilization) return flags; + const { postGestureStabilization: _postGestureStabilization, ...publicFlags } = flags; + return publicFlags; +} + function isPostGestureStabilizingAction( action: string, positionals: string[], diff --git a/src/daemon/handlers/__tests__/snapshot-handler-capture-retry.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-capture-retry.test.ts deleted file mode 100644 index 0d00967083..0000000000 --- a/src/daemon/handlers/__tests__/snapshot-handler-capture-retry.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { test, expect, vi, afterEach, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -import { - getRuntimeBindings, - mockTapPoint, - resetGetRuntimeFixture, -} from '../../__tests__/interaction-get-runtime-fixture.ts'; -import { captureSnapshot } from '../../snapshot-capture.ts'; -import { - isActiveProviderDevice, - setActiveProviderDeviceRuntimes, -} from '../../../provider-device-runtime.ts'; -import { installProviderDeviceAdmission } from '../../provider-device-admission.ts'; - -// The daemon reads provider ownership through its own typed admission seam; production -// installs it from root composition, and these tests compose it the same way. -installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); -import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; -import { buildNodes } from '../../../__tests__/test-utils/snapshot-builders.ts'; -import { resetSnapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; -import { - androidCapture, - androidDevice, - androidTextRows, - inboxBaselineNodes, - iosSimulatorDevice, - makeAndroidFreshnessSession, - makeSession, -} from './snapshot-handler.fixtures.ts'; - -vi.mock('../../snapshot-interactor-capture.ts', async () => { - const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); - return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; -}); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})) }; -}); - -// The real implementation shells out to simctl to probe for a hint-worthy -// unambiguous environment; that live-probe logic is covered by -// ios-app-session-hint.test.ts. Stubbed here so this suite stays hermetic and -// fast — defaults to "no enrichment", matching the current-behavior fallback. -vi.mock('../../ios-app-session-hint.ts', () => ({ - buildIosOpenCommandHint: vi.fn(async () => undefined), -})); - -import { runAppleRunnerCommand } from '@agent-device/platform-apple/runner/operations'; -import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; - -const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); -const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); - -afterEach(() => { - setActiveProviderDeviceRuntimes([]); -}); - -beforeEach(() => { - resetSnapshotRuntimeFixture(); - legacyDispatchCapture.mockReset(); - legacyDispatchCapture.mockResolvedValue({}); - resetGetRuntimeFixture(); - mockRunnerCommand.mockReset(); - mockRunnerCommand.mockResolvedValue({}); - mockBuildIosOpenCommandHint.mockReset(); - mockBuildIosOpenCommandHint.mockResolvedValue(undefined); -}); - -test('captureSnapshot lazily retries pending no-change touch before returning fresh state', async () => { - const sessionName = 'ios-lazy-outcome-retry'; - const session = makeSession(sessionName, iosSimulatorDevice); - const baselineNodes = [ - { - ref: 'e1', - index: 0, - depth: 0, - type: 'Button', - label: 'Open feed', - identifier: 'open-feed', - hittable: true, - rect: { x: 20, y: 120, width: 160, height: 48 }, - }, - ]; - session.snapshot = { - nodes: baselineNodes, - createdAt: Date.now(), - backend: 'xctest', - }; - session.pendingInteractionOutcome = { - action: 'click', - command: 'press', - positionals: ['100', '144'], - flags: { platform: 'ios' }, - markedAt: Date.now(), - attemptsRemaining: 2, - preSignature: [ - { - key: 'open-feed|Open feed||Button||hittable|#0', - identity: 'open-feed|Open feed||Button', - content: 'open-feed|Open feed||Button', - x: 20, - y: 120, - width: 160, - height: 48, - discriminating: true, - }, - ], - }; - - let pressed = false; - mockTapPoint.mockImplementation(async () => { - pressed = true; - return { clicked: true }; - }); - legacyDispatchCapture.mockImplementation(async () => { - return { - nodes: !pressed - ? baselineNodes - : [ - { - index: 0, - depth: 0, - type: 'Button', - label: 'Back', - identifier: 'back', - hittable: true, - rect: { x: 20, y: 60, width: 90, height: 44 }, - }, - { - index: 1, - depth: 0, - type: 'StaticText', - label: 'Feed', - rect: { x: 20, y: 140, width: 160, height: 48 }, - }, - ], - backend: 'xctest', - }; - }); - - const result = await captureSnapshot({ - device: iosSimulatorDevice, - session, - flags: { snapshotInteractiveOnly: true }, - logPath: '/tmp/daemon.log', - ...getRuntimeBindings(), - }); - - expect(result.snapshot.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'Feed' })]), - ); - // R58: the retry re-fires through the bound `tapPoint`, on the recorded coordinate pair. - expect(mockTapPoint).toHaveBeenCalledTimes(1); - expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 100, y: 144 }); - expect(session.pendingInteractionOutcome).toBeUndefined(); -}); - -test('captureSnapshot does not retry when a tap change appears after a short delay', async () => { - const sessionName = 'android-delayed-outcome-without-retry'; - const session = makeSession(sessionName, androidDevice); - const baselineNodes = [ - { - ref: 'e1', - index: 0, - depth: 0, - type: 'android.widget.Button', - label: 'Open drawer', - hittable: true, - rect: { x: 20, y: 120, width: 160, height: 48 }, - }, - ]; - const changedNodes = [ - { - index: 0, - depth: 0, - type: 'android.widget.TextView', - label: 'Albums', - rect: { x: 32, y: 240, width: 180, height: 52 }, - }, - ]; - session.pendingInteractionOutcome = { - action: 'click', - command: 'press', - positionals: ['100', '144'], - flags: { platform: 'android' }, - markedAt: Date.now(), - attemptsRemaining: 2, - preSignature: buildInteractionSurfaceSignature(baselineNodes), - }; - - let snapshotCalls = 0; - legacyDispatchCapture.mockImplementation(async (_device, command) => { - expect(command).toBe('snapshot'); - snapshotCalls += 1; - return { - nodes: snapshotCalls === 1 ? baselineNodes : changedNodes, - backend: 'android', - }; - }); - - const result = await captureSnapshot({ - device: androidDevice, - session, - flags: { snapshotInteractiveOnly: true }, - logPath: '/tmp/daemon.log', - }); - - expect(result.snapshot.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'Albums' })]), - ); - expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); - expect(session.pendingInteractionOutcome).toBeUndefined(); -}); - -test('captureSnapshot retries pending tap outcome before post-gesture stabilization', async () => { - const sessionName = 'android-maestro-tap-outcome-before-stabilization'; - const session = makeSession(sessionName, androidDevice); - const baselineNodes = [ - { - ref: 'e1', - index: 0, - depth: 0, - type: 'android.widget.Button', - label: 'Navigate to Third', - hittable: true, - rect: { x: 302, y: 1301, width: 476, height: 110 }, - }, - ]; - session.snapshot = { - nodes: baselineNodes, - createdAt: Date.now(), - backend: 'android', - }; - session.pendingInteractionOutcome = { - action: 'click', - command: 'press', - positionals: ['540', '1356'], - flags: { platform: 'android' }, - markedAt: Date.now(), - attemptsRemaining: 2, - preSignature: [ - { - key: '|Navigate to Third||android.widget.Button||hittable|#0', - identity: '|Navigate to Third||android.widget.Button', - content: '|Navigate to Third||android.widget.Button', - x: 302, - y: 1301, - width: 476, - height: 110, - discriminating: true, - }, - ], - }; - session.postGestureStabilization = { - action: 'click', - positionals: [], - markedAt: Date.now(), - }; - - let pressed = false; - mockTapPoint.mockImplementation(async () => { - pressed = true; - return { clicked: true }; - }); - legacyDispatchCapture.mockImplementation(async () => { - return { - nodes: !pressed - ? baselineNodes - : [ - { - index: 0, - depth: 0, - type: 'android.widget.TextView', - label: 'Tab Third (3)', - rect: { x: 390, y: 884, width: 300, height: 55 }, - }, - ], - backend: 'android', - }; - }); - - const result = await captureSnapshot({ - device: androidDevice, - session, - flags: { snapshotInteractiveOnly: true }, - logPath: '/tmp/daemon.log', - ...getRuntimeBindings(), - }); - - expect(result.snapshot.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'Tab Third (3)' })]), - ); - // R58: the retry re-fires through the bound `tapPoint`, on the recorded coordinate pair. - expect(mockTapPoint).toHaveBeenCalledTimes(1); - expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 540, y: 1356 }); - expect(session.pendingInteractionOutcome).toBeUndefined(); - expect(session.postGestureStabilization).toBeUndefined(); -}); - -test('captureSnapshot composes post-gesture stabilization with Android freshness capture', async () => { - const sessionName = 'android-post-gesture-freshness'; - const baselineNodes = inboxBaselineNodes(18); - const changedNodes = buildNodes( - androidTextRows(18, (row) => (row === 1 ? 'album-0' : `Album row ${row}`)), - ); - const session = makeAndroidFreshnessSession(sessionName, 'click', baselineNodes); - session.postGestureStabilization = { - action: 'click', - positionals: [], - markedAt: Date.now(), - }; - - legacyDispatchCapture - .mockResolvedValueOnce(androidCapture(baselineNodes, { rawNodeCount: 18, maxDepth: 1 })) - .mockResolvedValueOnce(androidCapture(changedNodes, { rawNodeCount: 18, maxDepth: 1 })) - .mockResolvedValueOnce(androidCapture(changedNodes, { rawNodeCount: 18, maxDepth: 1 })); - - const result = await captureSnapshot({ - device: androidDevice, - session, - flags: { snapshotInteractiveOnly: true }, - logPath: '/tmp/daemon.log', - }); - - expect(result.snapshot.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'album-0' })]), - ); - expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual([ - 'snapshot', - 'snapshot', - 'snapshot', - ]); - expect(session.androidSnapshotFreshness).toBeUndefined(); - expect(session.postGestureStabilization).toBeUndefined(); -}); - -test('captureSnapshot composes pending outcome retry with Android freshness capture', async () => { - const sessionName = 'android-lazy-outcome-freshness'; - const baselineNodes = inboxBaselineNodes(18); - const session = makeAndroidFreshnessSession(sessionName, 'click', baselineNodes); - session.pendingInteractionOutcome = { - action: 'click', - command: 'press', - positionals: ['180', '330'], - flags: { platform: 'android' }, - markedAt: Date.now(), - attemptsRemaining: 2, - preSignature: buildInteractionSurfaceSignature(baselineNodes), - }; - - legacyDispatchCapture - .mockResolvedValueOnce(androidCapture([], { rawNodeCount: 18, maxDepth: 1 })) - .mockResolvedValueOnce( - androidCapture( - [ - { - index: 0, - depth: 0, - type: 'android.widget.Button', - label: 'Create document', - hittable: true, - }, - ], - { rawNodeCount: 1, maxDepth: 0 }, - ), - ); - - const result = await captureSnapshot({ - device: androidDevice, - session, - flags: { snapshotInteractiveOnly: true }, - logPath: '/tmp/daemon.log', - }); - - expect(result.snapshot.nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), - ); - expect(result.freshness).toEqual({ - action: 'click', - retryCount: 1, - staleAfterRetries: false, - reason: undefined, - }); - expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); - expect(session.pendingInteractionOutcome).toBeUndefined(); - expect(session.androidSnapshotFreshness).toBeUndefined(); -}); diff --git a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts index 2712c6ab27..b9b340b0fe 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts @@ -14,7 +14,6 @@ import { installProviderDeviceAdmission } from '../../provider-device-admission. // installs it from root composition, and these tests compose it the same way. installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); import { AppError } from '@agent-device/kernel/errors'; -import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; @@ -139,7 +138,7 @@ function assertAndroidTimeoutEvidencePayload(evidence: unknown) { expect(record.overlayRefs).toEqual([expect.objectContaining({ ref: 'e1', label: 'Continue' })]); } -test('snapshot annotations survive pending interaction capture into CLI JSON', async () => { +test('snapshot annotations survive a deferred post-gesture capture into CLI JSON', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-interaction-annotation-bundle'; const session = makeSession(sessionName, androidDevice); @@ -164,15 +163,8 @@ test('snapshot annotations survive pending interaction capture into CLI JSON', a }, ]; const snapshotQuality = { state: 'healthy', backend: 'tree' }; - session.pendingInteractionOutcome = { - action: 'click', - command: 'press', - positionals: ['100', '144'], - flags: { platform: 'android' }, - markedAt: Date.now(), - attemptsRemaining: 2, - preSignature: buildInteractionSurfaceSignature(baselineNodes), - }; + session.snapshot = { nodes: baselineNodes, createdAt: Date.now(), backend: 'android' }; + session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() }; sessionStore.set(sessionName, session); legacyDispatchCapture.mockResolvedValue({ diff --git a/src/daemon/interaction-retry-tap.ts b/src/daemon/interaction-retry-tap.ts deleted file mode 100644 index 95231b65c7..0000000000 --- a/src/daemon/interaction-retry-tap.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { InteractionRetryTap } from './interaction-outcome-policy.ts'; -import type { RuntimeAdmissionBindings } from './request-runtime-binding.ts'; -import { createBoundTouchExecutor, resolveBoundTouchRuntime } from './touch-runtime.ts'; - -/** - * Supplies the interaction-outcome policy with the one device effect it needs: re-firing a - * recorded coordinate tap through the same bound `tapPoint` the original press used (R48). - * - * It lives beside neither party. The policy decides whether a retry is warranted and must stay - * readable without the binding stack, so it declares the seam and never imports admission; the - * capture route holds the request's bindings but has no business knowing which touch plan a - * re-fired tap needs. This is the adapter between them, and every request route that can retry - * builds it from its own bindings rather than reaching into a module-level one. - * - * Answers `undefined` for a route with no bindings to admit with — an internal capture that - * decorates someone else's request cannot fire a tap, and the policy reports that as a skip. - */ -export function createInteractionRetryTap( - bindings: RuntimeAdmissionBindings, -): InteractionRetryTap | undefined { - const { inspectFacts, bindDevice } = bindings; - if (!inspectFacts || !bindDevice) return undefined; - // ADR 0019 §9 is one admission per handler, and the outcome policy can call this seam once per - // retry round. Memoizing per device keeps the facts inspection and the narrowing to the first - // round: `bindDevice` already caches the underlying binding, so re-resolving would only re-read - // facts the request has already admitted on. - const resolutions = new Map>(); - return async ({ device, point, context }) => { - // Admission runs before the policy spends an attempt: an owner whose cell cannot tap answers - // `false` here and leaves the pending record intact. - let resolution = resolutions.get(device.id); - if (!resolution) { - resolution = resolveBoundTouchRuntime({ - device, - command: 'press', - requiresCapture: false, - inspectFacts, - bindDevice, - }); - resolutions.set(device.id, resolution); - } - const bound = await resolution; - if (!bound.ok) return false; - const executor = createBoundTouchExecutor(bound.runtime, context); - if (!executor.tapPoint) return false; - await executor.tapPoint(point); - return true; - }; -} diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-surface-signature.ts similarity index 63% rename from src/daemon/interaction-outcome-policy.ts rename to src/daemon/interaction-surface-signature.ts index 40c78cea55..191171a342 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-surface-signature.ts @@ -1,244 +1,15 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; -import { isMobilePlatform } from '@agent-device/kernel/device'; import type { Rect, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { collectKeyboardChromeRefs } from '@agent-device/capture-kit/snapshot-chrome'; import { stateMarkers } from '@agent-device/capture-kit/snapshot-lines'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isViewportRootNode } from '@agent-device/contracts/snapshot'; -import { contextFromFlags, type DaemonCommandContext } from './context.ts'; -import type { SessionState } from './session-state.ts'; +import type { InteractionSurfaceEntry } from './session-state.ts'; -const OUTCOME_RETRY_WINDOW_MS = 30_000; -const OUTCOME_RETRY_ATTEMPTS = 2; const RECT_TOLERANCE_PX = 1; -export type InteractionSurfaceSignature = NonNullable< - SessionState['pendingInteractionOutcome'] ->['preSignature']; +export type InteractionSurfaceSignature = InteractionSurfaceEntry[]; export type InteractionSurfaceChange = 'changed' | 'unchanged' | 'ambiguous'; -/** - * How this policy re-fires a recorded tap. The policy owns *whether* a retry is warranted; the - * request route that already admitted a device cell owns *how* the tap reaches the device, and - * supplies it here. Keeping the seam a plain callback is what stops the policy from importing - * runtime admission — a read of "should we retry?" must stay readable without the binding stack. - * - * Answers `false` when the caller's owner cannot tap, so the policy can report a skipped retry - * instead of burning an attempt. - */ -export type InteractionRetryTap = ( - request: Readonly<{ - device: SessionState['device']; - point: Readonly<{ x: number; y: number }>; - context: InteractionRetryContext; - }>, -) => Promise; - -/** The runner metadata a re-fired tap carries — the same context any bound touch executes on. */ -export type InteractionRetryContext = DaemonCommandContext & - Readonly<{ surface?: SessionState['surface'] }>; - -function shouldRetryTouchOnNoChange(flags: CommandFlags | undefined): boolean { - return flags?.interactionOutcome?.retryOnNoChange === true; -} - -export function markPendingInteractionOutcome(params: { - session: SessionState; - command: string; - positionals: string[]; - flags: CommandFlags | undefined; - preSnapshot: SnapshotState | undefined; -}): void { - const { session, command, positionals, flags, preSnapshot } = params; - if (!shouldRetryTouchOnNoChange(flags)) return; - if (!supportsInteractionOutcomePolicy(session)) return; - const retryCommand = retryCommandForTap(command); - if (!retryCommand) return; - if (!isCoordinatePair(positionals)) return; - const preSignature = buildInteractionSurfaceSignature(preSnapshot?.nodes ?? []); - if (preSignature.length === 0) return; - session.pendingInteractionOutcome = { - action: command, - command: retryCommand, - positionals, - flags: stripInternalInteractionFlags(flags), - markedAt: Date.now(), - attemptsRemaining: OUTCOME_RETRY_ATTEMPTS, - preSignature, - }; -} - -export function getActivePendingInteractionOutcome( - session: SessionState | undefined, -): NonNullable | undefined { - const pending = session?.pendingInteractionOutcome; - if (!session || !pending) return undefined; - if (!supportsInteractionOutcomePolicy(session)) { - clearPendingInteractionOutcome(session); - return undefined; - } - if (Date.now() - pending.markedAt > OUTCOME_RETRY_WINDOW_MS) { - clearPendingInteractionOutcome(session); - return undefined; - } - return pending; -} - -export function clearPendingInteractionOutcome(session: SessionState | undefined): void { - if (!session?.pendingInteractionOutcome) return; - session.pendingInteractionOutcome = undefined; -} - -export async function retryPendingInteractionOutcome(params: { - session: SessionState; - pending: NonNullable; - logPath: string; - snapshot: SnapshotState; - retryTap?: InteractionRetryTap; -}): Promise<{ retried: boolean; change: InteractionSurfaceChange }> { - const { pending, snapshot } = params; - const change = classifyInteractionSurfaceChange( - pending.preSignature, - buildInteractionSurfaceSignature(snapshot.nodes), - ); - if (change !== 'unchanged' || pending.attemptsRemaining <= 0) { - return { retried: false, change }; - } - - // The retry re-fires the same coordinate tap the original press used (R48). Nothing was - // attempted when this capture path carries no retry seam or the recorded coordinates are - // unreadable, so those leave the pending record whole instead of burning an attempt, and say so - // rather than letting the caller infer it from an unchanged surface. - const retryTap = params.retryTap; - const point = retryTap ? readRetryPoint(pending.positionals) : undefined; - if (!retryTap || !point) { - emitSkippedRetry(pending, retryTap ? 'unreadable-retry-point' : 'device-runtime-unavailable'); - return { retried: false, change }; - } - - const startedAt = Date.now(); - // Spent before the device work, matching the retired route: an owner that refuses the tap or - // fails mid-flight has still consumed the attempt, so the next capture inside the pending - // window cannot re-attempt it from a full budget. - pending.attemptsRemaining -= 1; - // Opt-in Maestro retries intentionally re-fire the same coordinate tap; delayed or - // non-visual side effects can duplicate, but unchanged visual taps are the target gap. - const fired = await fireRetryTap(retryTap, params); - if (!fired) { - emitSkippedRetry(pending, 'retry-tap-unavailable'); - return { retried: false, change }; - } - - emitDiagnostic({ - level: 'info', - phase: 'interaction_no_change_retry', - data: { - action: pending.action, - attemptsRemaining: pending.attemptsRemaining, - durationMs: Date.now() - startedAt, - }, - }); - return { retried: true, change }; -} - -/** - * The seam is allowed to refuse and allowed to fail; neither may escape into the capture this - * retry decorates. A press whose re-fire dies on the device leaves the caller with the honest - * unchanged surface plus a diagnostic, not a failed `snapshot`. - */ -async function fireRetryTap( - retryTap: InteractionRetryTap, - params: Readonly<{ - session: SessionState; - pending: NonNullable; - logPath: string; - }>, -): Promise { - const { session, pending } = params; - const point = readRetryPoint(pending.positionals); - if (!point) return false; - try { - return await retryTap({ - device: session.device, - point, - context: { - ...contextFromFlags( - params.logPath, - pending.flags, - session.appBundleId, - session.trace?.outPath, - ), - surface: session.surface, - }, - }); - } catch { - return false; - } -} - -/** Never silent: a retry the opt-in flag asked for and this request did not deliver says why. */ -function emitSkippedRetry( - pending: NonNullable, - reason: 'device-runtime-unavailable' | 'unreadable-retry-point' | 'retry-tap-unavailable', -): void { - emitDiagnostic({ - level: 'info', - phase: 'interaction_no_change_retry_skipped', - data: { - action: pending.action, - attemptsRemaining: pending.attemptsRemaining, - reason, - }, - }); -} - -export function emitInteractionSettled(params: { - pending: NonNullable; - change: InteractionSurfaceChange; - attempts: number; - startedAt: number; -}): void { - emitDiagnostic({ - level: params.attempts > 0 ? 'info' : 'debug', - phase: 'interaction_settled', - data: { - action: params.pending.action, - change: params.change, - attempts: params.attempts, - durationMs: Date.now() - params.startedAt, - }, - }); -} - -export function emitInteractionSettleTimeout(params: { - pending: NonNullable; - attempts: number; - startedAt: number; -}): void { - emitDiagnostic({ - level: 'warn', - phase: 'interaction_settle_timeout', - data: { - action: params.pending.action, - attempts: params.attempts, - durationMs: Date.now() - params.startedAt, - }, - }); -} - -export function stripInternalInteractionFlags( - flags: CommandFlags | undefined, -): CommandFlags | undefined { - if (!flags?.interactionOutcome && !flags?.postGestureStabilization) return flags; - const { - interactionOutcome: _interactionOutcome, - postGestureStabilization: _postGestureStabilization, - ...publicFlags - } = flags; - return publicFlags; -} - export function buildInteractionSurfaceSignature( nodes: SnapshotNode[], ): InteractionSurfaceSignature { @@ -268,15 +39,6 @@ export function snapshotSurfaceComparisonKey( return snapshot?.comparisonKey ?? snapshot?.snapshotQuality?.backend; } -export function classifyInteractionSurfaceChange( - before: InteractionSurfaceSignature, - after: InteractionSurfaceSignature, -): InteractionSurfaceChange { - if (before.length === 0 || after.length === 0) return 'ambiguous'; - if (areInteractionSurfaceSignaturesStable(before, after)) return 'unchanged'; - return 'changed'; -} - /** * Shared rect-tolerance comparison for the surface-stability checks in this * module. Entry rects are already rounded by `buildInteractionSurfaceEntry`, @@ -521,26 +283,6 @@ function discriminatingEntriesWithinRect( ); } -function supportsInteractionOutcomePolicy(session: SessionState): boolean { - return isMobilePlatform(session.device); -} - -/** - * The pending record stores the coordinate pair `isCoordinatePair` already validated, so this - * only re-reads it; a record that somehow fails the read is skipped rather than retried blind. - */ -function readRetryPoint(positionals: readonly string[]): { x: number; y: number } | undefined { - const x = Number(positionals[0]); - const y = Number(positionals[1]); - return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; -} - -function retryCommandForTap(command: string): string | undefined { - if (command === 'click') return 'press'; - if (command === 'press') return 'press'; - return undefined; -} - function buildInteractionSurfaceEntry( node: SnapshotNode, occurrenceCounts: Map, @@ -609,7 +351,7 @@ function isNonDiscriminatingSurfaceNode( /** * What the element is and the state it is in. The states are the ones `stateMarkers` prints, so the * outcome lane, the unchanged-snapshot comparison, and the diff weigh one list: a tap whose only - * effect is a toggle is a change here, not a no-op to retry. + * effect is a toggle is a change here. */ function interactionSurfaceSemanticKey(node: SnapshotNode): string | undefined { const semanticKey = [ @@ -626,11 +368,6 @@ function interactionSurfaceSemanticKey(node: SnapshotNode): string | undefined { return semanticKey.replaceAll('|', '') ? semanticKey : undefined; } -function isCoordinatePair(positionals: string[]): boolean { - if (positionals.length !== 2) return false; - return positionals.every((value) => Number.isFinite(Number(value))); -} - function isFiniteRect(rect: NonNullable): boolean { const values = [rect.x, rect.y, rect.width, rect.height]; return values.every((value) => Number.isFinite(value)) && rect.width > 0 && rect.height > 0; diff --git a/src/daemon/interaction/internal/__tests__/find.test.ts b/src/daemon/interaction/internal/__tests__/find.test.ts index 43f8396894..6790048058 100644 --- a/src/daemon/interaction/internal/__tests__/find.test.ts +++ b/src/daemon/interaction/internal/__tests__/find.test.ts @@ -519,12 +519,12 @@ test('handleFindCommands focus rejects covered matches before dispatching coordi expect(mockDispatch.mock.calls.filter((call) => call[1] === 'focus')).toEqual([]); }); -test('handleFindCommands forwards internal interaction outcome flags only to delegated click', async () => { +test('handleFindCommands forwards internal interaction flags only to delegated click', async () => { const { response, invokeCalls, session } = await runFindClickScenario({ positionals: ['Continue', 'click'], flags: { findFirst: true, - interactionOutcome: { retryOnNoChange: true }, + postGestureStabilization: true, }, nodes: [ { @@ -545,7 +545,7 @@ test('handleFindCommands forwards internal interaction outcome flags only to del }); expect(response.ok).toBe(true); - expect(invokeCalls[0]!.flags?.interactionOutcome).toEqual({ retryOnNoChange: true }); + expect(invokeCalls[0]!.flags?.postGestureStabilization).toBe(true); expect(session.actions.at(-1)?.flags).toEqual({}); }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts index 174ef7c2a7..d357fb77db 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts @@ -7,7 +7,6 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; import { handleInteractionCommands } from '../../index.ts'; -import { handleSnapshotCommands } from '../../../handlers/snapshot.ts'; import { makeIosSession, authoringPublication, @@ -22,7 +21,6 @@ import { snapshot, snapshotPayload, } from './interaction-ios-tap-outcome-fixtures.ts'; -import { snapshotRuntimeFixture } from '../../../__tests__/snapshot-runtime-fixture.ts'; import { appCaptureComparisonKey, systemSurfaceCaptureComparisonKey, @@ -621,7 +619,7 @@ test('runtime-resolved taps use the same corroboration boundary', async () => { expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); -test('a corroborated runtime coordinate tap does not schedule a no-change retry', async () => { +test('runtime coordinate taps use the same corroboration boundary', async () => { const sessionName = 'ios-runtime-coordinate-corroboration'; const sessionStore = makeSessionStore(); sessionStore.set( @@ -631,17 +629,12 @@ test('a corroborated runtime coordinate tap does not schedule a no-change retry' snapshot: snapshot(profileNodes), }), ); - let pressCount = 0; legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { - pressCount += 1; - if (pressCount === 1) { - throw new AppError( - 'XCTEST_RECORDED_FAILURE', - 'XCTest recorded a failure while executing tap; the action may not have been performed.', - ); - } - return {}; + throw new AppError( + 'XCTEST_RECORDED_FAILURE', + 'XCTest recorded a failure while executing tap; the action may not have been performed.', + ); } if (command === 'snapshot') return snapshotPayload(imageViewerNodes); return {}; @@ -649,30 +642,12 @@ test('a corroborated runtime coordinate tap does not schedule a no-change retry' const clickResponse = await runClick(sessionStore, sessionName, { positionals: ['104', '222'], - flags: { interactionOutcome: { retryOnNoChange: true } }, }); expect(clickResponse?.ok).toBe(true); if (clickResponse?.ok) { expect(clickResponse.data?.warning).toMatch(/post-action accessibility capture changed/); } - - const snapshotResponse = await handleSnapshotCommands({ - req: { - token: 'test', - session: sessionName, - command: 'snapshot', - positionals: [], - flags: {}, - }, - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - ...snapshotRuntimeFixture(), - }); - - expect(snapshotResponse?.ok).toBe(true); - expect(pressCount).toBe(1); - expect(sessionStore.get(sessionName)?.pendingInteractionOutcome).toBeUndefined(); + expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); test('corroborated runtime taps retain target evidence through save and replay', async () => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts index b1e96493f8..6368761b3f 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts @@ -12,7 +12,7 @@ import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; // What the shared runtime dispatch does with the resolved target: refuse // unusable frame evidence rather than recapture positionally (ADR 0014), refuse -// off-screen targets, and store the coordinates a lazy outcome retry replays. +// off-screen targets, and record the `@ref` the caller wrote. const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), @@ -66,25 +66,22 @@ beforeEach(() => { mockRunAppleRunnerCommand.mockResolvedValue({}); }); -test('press @ref stores resolved coordinate retry payload for lazy outcome retry', async () => { +test('press @ref taps the resolved point once and records the ref', async () => { const sessionStore = makeSessionStore(); - const sessionName = 'retry-ref'; + const sessionName = 'press-ref'; const session = makeSession(sessionName); - session.snapshot = { - nodes: attachRefs([ - { - index: 0, - type: 'XCUIElementTypeButton', - label: 'Continue', - identifier: 'auth_continue', - rect: { x: 10, y: 20, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, - ]), - createdAt: Date.now(), - backend: 'xctest', - }; + const nodes = attachRefs([ + { + index: 0, + type: 'XCUIElementTypeButton', + label: 'Continue', + identifier: 'auth_continue', + rect: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + hittable: true, + }, + ]); + session.snapshot = { nodes, createdAt: Date.now(), backend: 'xctest' }; sessionStore.set(sessionName, session); const response = await handleInteractionCommands({ @@ -93,7 +90,7 @@ test('press @ref stores resolved coordinate retry payload for lazy outcome retry session: sessionName, command: 'press', positionals: ['@e1'], - flags: { interactionOutcome: { retryOnNoChange: true } }, + flags: {}, }, sessionName, sessionStore, @@ -102,9 +99,9 @@ test('press @ref stores resolved coordinate retry payload for lazy outcome retry }); expect(response?.ok).toBe(true); + expect(mockTapPoint).toHaveBeenCalledTimes(1); + expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 60, y: 40 }); const stored = sessionStore.get(sessionName); - expect(stored?.pendingInteractionOutcome?.command).toBe('press'); - expect(stored?.pendingInteractionOutcome?.positionals).toEqual(['60', '40']); expect(stored?.actions[0]?.positionals).toEqual(['@e1']); expect(stored?.actions[0]?.flags).toEqual({}); }); diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 8a24349bc5..4b25bdfc64 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -21,7 +21,7 @@ import { readCommandMessage, successText } from '@agent-device/kernel/success-te import type { RequestCaptureProof } from '../../capture-disclosure.ts'; import { withCaptureDisclosures } from '../../capture-disclosure.ts'; import { recordSessionAction } from '../../session-action-recorder.ts'; -import { stripInternalInteractionFlags } from '../../interaction-outcome-policy.ts'; +import { stripInternalInteractionFlags } from '../../deferred-interaction-outcome.ts'; import { resolveFindMatch } from './find-match-resolution.ts'; import { executeFocusPoint } from '../../focus-runtime.ts'; import { executeBoundTypeText } from '../../type-text-runtime.ts'; diff --git a/src/daemon/interaction/internal/interaction-common.ts b/src/daemon/interaction/internal/interaction-common.ts index 4f7d21bf98..73f0694c72 100644 --- a/src/daemon/interaction/internal/interaction-common.ts +++ b/src/daemon/interaction/internal/interaction-common.ts @@ -1,7 +1,7 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { DaemonResponse } from '../../daemon-request.ts'; -import { stripInternalInteractionFlags } from '../../interaction-outcome-policy.ts'; +import { stripInternalInteractionFlags } from '../../deferred-interaction-outcome.ts'; import { computeTargetEvidence, type RecordedTargetCapture, @@ -16,13 +16,11 @@ export function finalizeTouchInteraction(params: { command: string; positionals: string[]; actionCommand?: string; - retryPositionals?: string[]; flags: CommandFlags | undefined; result: Record; responseData: Record; recordedTarget?: RecordedTargetCapture; recordedTargets?: { source: RecordedTargetCapture; destination: RecordedTargetCapture }; - scheduleInteractionOutcomeRetry?: boolean; actionStartedAt: number; actionFinishedAt: number; androidFreshnessBaseline?: SnapshotState | undefined; @@ -32,13 +30,11 @@ export function finalizeTouchInteraction(params: { command, positionals, actionCommand = command, - retryPositionals, flags, result, responseData, recordedTarget, recordedTargets, - scheduleInteractionOutcomeRetry = true, actionStartedAt, actionFinishedAt, androidFreshnessBaseline, @@ -70,9 +66,8 @@ export function finalizeTouchInteraction(params: { operations.markDeferredOutcome({ command, action: actionCommand, - positionals: retryPositionals ?? positionals, + positionals, flags, - scheduleOutcomeRetry: scheduleInteractionOutcomeRetry, androidFreshnessBaseline, }); operations.recordGestureVisualization( diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index 90a00fa6c9..a6cce12d9f 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -16,7 +16,6 @@ import { buildCorroboratedTapResponseData, buildInteractionResponseData, maestroFallbackDisclosure, - pointPositionals, readInteractionResponseDataTransformCommand, transformTouchResponseData, } from './interaction-touch-response.ts'; @@ -77,7 +76,6 @@ export async function dispatchDirectIosSelectorTap( sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? [], - retryPositionals: pointPositionals(point), flags: handlerParams.req.flags, result, responseData, @@ -152,7 +150,6 @@ async function buildDirectIosCorroboratedResponse(params: { flags: handlerParams.req.flags, result, responseData, - scheduleInteractionOutcomeRetry: false, actionStartedAt, actionFinishedAt: Date.now(), }); diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index 5924dca3c3..116162a8cc 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -370,8 +370,3 @@ export function maestroFallbackDisclosure( }, }; } - -/** The coordinate a lazy outcome retry re-dispatches against (`finalizeTouchInteraction`). */ -export function pointPositionals(point: { x: number; y: number }): string[] { - return [String(point.x), String(point.y)]; -} diff --git a/src/daemon/interaction/internal/interaction-touch-runtime.ts b/src/daemon/interaction/internal/interaction-touch-runtime.ts index e4aca1fbb1..6bbc473006 100644 --- a/src/daemon/interaction/internal/interaction-touch-runtime.ts +++ b/src/daemon/interaction/internal/interaction-touch-runtime.ts @@ -28,7 +28,6 @@ import { readSnapshotNodesReferenceFrame } from '@agent-device/capture-kit/touch import { buildCorroboratedTapResponseData, buildInteractionResponseData, - pointPositionals, type InteractionResponsePayloads, } from './interaction-touch-response.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; @@ -111,7 +110,6 @@ export async function dispatchRuntimeInteraction< sessionStore: params.sessionStore, command: params.req.command, positionals: params.req.positionals ?? [], - retryPositionals: retryPositionalsForRuntimeResult(params.req.command, runtimeResult), flags: params.req.flags, result, responseData, @@ -184,7 +182,6 @@ async function buildRuntimeIosCorroboratedResponse(params: { result: payloads.result, responseData: payloads.responseData, recordedTarget: payloads.recordedTarget, - scheduleInteractionOutcomeRetry: false, actionStartedAt: params.actionStartedAt, actionFinishedAt: Date.now(), androidFreshnessBaseline: params.androidFreshnessBaseline, @@ -229,15 +226,3 @@ function pointFromInteractionTarget( function appErrorResponse(error: unknown): DaemonResponse { return { ok: false, error: normalizeError(error) }; } - -function retryPositionalsForRuntimeResult( - command: string, - result: PressCommandResult | FillCommandResult | LongPressCommandResult, -): string[] | undefined { - if (result.kind === 'ref' && !result.node) return undefined; - if (command === 'click' || command === 'press') { - if (!result.point) return undefined; - return pointPositionals(result.point); - } - return undefined; -} diff --git a/src/daemon/scroll-movement.ts b/src/daemon/scroll-movement.ts index 2f0d124614..a05c112964 100644 --- a/src/daemon/scroll-movement.ts +++ b/src/daemon/scroll-movement.ts @@ -29,7 +29,7 @@ import { summarizeDiscriminatingSurfaceDivergence, type InteractionSurfaceChange, type InteractionSurfaceSignature, -} from './interaction-outcome-policy.ts'; +} from './interaction-surface-signature.ts'; import { refFrameState } from './ref-frame.ts'; import { isPostGestureStabilizationPending } from './deferred-interaction-outcome.ts'; import type { SessionState } from './session-state.ts'; @@ -46,7 +46,7 @@ import type { SessionState } from './session-state.ts'; * * The evidence rules are not a second opinion on that pair. The signature, the subset-tolerant * baseline classifier, and the strict no-effect bar all belong to - * `interaction-outcome-policy.ts`, the same comparators the deferred post-gesture stabilization + * `interaction-surface-signature.ts`, the same comparators the deferred post-gesture stabilization * applies to every other gesture, so "that scroll did nothing" means one thing in this daemon. * What is new here is only WHEN the answer is owed: a directional scroll pays one capture to gate its * own reply instead of leaving the proof to the next command's snapshot. diff --git a/src/daemon/session-state.ts b/src/daemon/session-state.ts index b829717e5a..8e3350ff04 100644 --- a/src/daemon/session-state.ts +++ b/src/daemon/session-state.ts @@ -1,4 +1,3 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; import type { SnapshotDiagnosticsState } from '@agent-device/contracts/capture'; import type { AppLogFailure, AppLogLiveHandle } from '@agent-device/contracts/app-log-runtime'; import type { AudioProbeLiveHandle } from '@agent-device/contracts/audio-probe-runtime'; @@ -68,7 +67,7 @@ export type InteractionSurfaceEntry = { * False for structurally fixed elements (the viewport root, keyboard * chrome) whose rect is invariant regardless of any gesture — shared * evidence limited to these is not evidence at all. See - * `classifyBaselineSurfaceEvidence` in interaction-outcome-policy.ts. + * `classifyBaselineSurfaceEvidence` in interaction-surface-signature.ts. */ discriminating: boolean; }; @@ -100,16 +99,6 @@ export type PostGestureStabilization = { baselineBackend?: string; }; -export type PendingInteractionOutcome = { - action: string; - command: string; - positionals: string[]; - flags?: CommandFlags; - markedAt: number; - attemptsRemaining: number; - preSignature: InteractionSurfaceEntry[]; -}; - /** * A session together with the store key that addresses it: `address` is the exact string * `--session` must carry to reach `session`, and it is NOT always `session.name`. An implicitly @@ -197,7 +186,6 @@ export type SessionState = { lastComparisonSafeSnapshot?: SnapshotState; androidSnapshotFreshness?: SnapshotFreshnessWindow; postGestureStabilization?: PostGestureStabilization; - pendingInteractionOutcome?: PendingInteractionOutcome; snapshotDiagnostics?: SnapshotDiagnosticsState; trace?: { outPath: string; diff --git a/src/daemon/snapshot-capture.ts b/src/daemon/snapshot-capture.ts index 927b3d98ff..d701fb9b90 100644 --- a/src/daemon/snapshot-capture.ts +++ b/src/daemon/snapshot-capture.ts @@ -25,9 +25,7 @@ import { clearAndroidSnapshotFreshness } from './session-snapshot-freshness.ts'; import type { SnapshotFreshnessMode } from '@agent-device/capture-kit/snapshot-freshness'; import { contextFromFlags } from './context.ts'; import { resolveDeferredInteractionOutcome } from './deferred-interaction-outcome.ts'; -import { createInteractionRetryTap } from './interaction-retry-tap.ts'; import type { SessionState } from './session-state.ts'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import { type DaemonFailureResponse, errorResponse } from '@agent-device/kernel/contracts'; type CaptureSnapshotParams = { @@ -46,13 +44,6 @@ type CaptureSnapshotParams = { * until their own command descriptor cuts over. */ captureData?: () => Promise; - /** - * The pending-outcome retry re-fires a bound `tapPoint` (R48), so a capture that can settle a - * deferred interaction outcome carries the request's own runtime bindings and builds the retry - * seam from them. A caller that has none simply never retries. - */ - inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; }; type SnapshotData = { @@ -80,11 +71,9 @@ export async function captureSnapshot( const deferred = await resolveDeferredInteractionOutcome({ session: params.session, device: params.device, - logPath: params.logPath, interactiveOnly: params.flags?.snapshotInteractiveOnly === true, androidFreshnessMode: params.androidFreshnessMode, capture: () => captureSnapshotAttempt(params), - retryTap: createInteractionRetryTap(params), }); if (deferred) return deferred; diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index cbaabbfe90..a83882e039 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -11,7 +11,6 @@ import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; import type { CommandSessionRecord } from '../runtime-contract.ts'; import { createCommandSurfaceAgentDevice } from '../runtime-command-surface.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; -import type { RuntimeAdmissionBindings } from './request-runtime-binding.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { captureSnapshot } from './snapshot-capture.ts'; import { buildSnapshotSession, withSessionlessRunnerCleanup } from './snapshot-session.ts'; @@ -76,8 +75,6 @@ export async function dispatchSnapshotRuntimeCommand( snapshotScope, capturedQuality, captureSnapshotData: capture.captureSnapshot, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, }); let result: Awaited>; try { @@ -116,19 +113,17 @@ export async function dispatchSnapshotRuntimeCommand( ); } -function createSnapshotRuntime( - params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - session: SessionState | undefined; - device: SessionState['device']; - snapshotScope: string | undefined; - capturedQuality: CapturedSnapshotQuality; - captureSnapshotData: () => Promise; - } & RuntimeAdmissionBindings, -) { +function createSnapshotRuntime(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + capturedQuality: CapturedSnapshotQuality; + captureSnapshotData: () => Promise; +}) { const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params; return createCommandSurfaceAgentDevice({ backend: createDaemonSnapshotBackend({ @@ -139,8 +134,6 @@ function createSnapshotRuntime( snapshotScope, capturedQuality: params.capturedQuality, captureSnapshotData: params.captureSnapshotData, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, }), ...createDaemonRuntimePolicy('snapshot'), signal: getRequestSignal(req.meta?.requestId), @@ -239,17 +232,15 @@ function resolveNextSnapshotScopeSource(params: { return current?.snapshotScopeSource ?? current?.snapshot; } -function createDaemonSnapshotBackend( - params: { - req: DaemonRequest; - logPath: string; - session: SessionState | undefined; - device: SessionState['device']; - snapshotScope: string | undefined; - capturedQuality: CapturedSnapshotQuality; - captureSnapshotData: () => Promise; - } & RuntimeAdmissionBindings, -): AgentDeviceBackend { +function createDaemonSnapshotBackend(params: { + req: DaemonRequest; + logPath: string; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + capturedQuality: CapturedSnapshotQuality; + captureSnapshotData: () => Promise; +}): AgentDeviceBackend { const { req, logPath, session, device, snapshotScope } = params; return { platform: publicPlatformString(device), @@ -263,10 +254,6 @@ function createDaemonSnapshotBackend( snapshotScope, signal: context.signal, captureData: params.captureSnapshotData, - // R48's pending-outcome retry re-fires a bound `tapPoint`, so the `snapshot` that settles - // a deferred outcome carries the request's own bindings down to the capture. - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, }); const annotations = snapshotCaptureAnnotationsFrom(capture); params.capturedQuality.value = annotations.quality; diff --git a/test/wire-compat/closure-policy.ts b/test/wire-compat/closure-policy.ts index 98d9d72172..cafce22fd8 100644 --- a/test/wire-compat/closure-policy.ts +++ b/test/wire-compat/closure-policy.ts @@ -76,7 +76,7 @@ export const WIRE_CLOSURE_WAIVERS: Readonly> = { 'packages/contracts/src/request-envelope.ts#InternalRequestOptions': 'CLI-side option projection; reaches the peer inside DaemonRequest.input/flags (Record, both listed), and ADR 0006 calls new flags additive.', 'packages/contracts/src/command-flags.ts#CommandFlags': - 'CLI-side flag vocabulary; reaches the peer inside DaemonRequest.flags (Record, listed), and ADR 0006 calls new flags additive.', + 'CLI-side flag vocabulary; reaches the peer inside DaemonRequest.flags (Record, listed), and ADR 0006 calls new flags additive. Removing a flag is covered too: flags travel as an untyped record, so an unknown key is dropped.', }; export function isExternalWireSpecifier(specifier: string): boolean { From 54e6cea9b15ca4ff1ad392cd0528540df304cef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 11:05:35 +0200 Subject: [PATCH 38/42] fix(apple-runner): restart without resending a command written then lost (#3082) * fix(apple-runner): name the lost reply when status cannot place a lost command A command whose reply was lost and whose status probe answers notAccepted (a restarted runner's empty journal) or an unnamed state now fails with details.reason runner_reply_lost beside dispatched unknown, so a caller keys on the typed reason instead of the message before it observes the screen. Tests (fake runner server through runAppleRunnerCommand and the recovery module): - runner-recovery-wiring: press and fill (runner tap and type) accept the command, drop the connection, status answers notAccepted: one dispatched command, error dispatched unknown with reason runner_reply_lost, the session is invalidated, and the next command gets a runner. Mutation: delete `reason: RUNNER_REPLY_LOST_REASON` from the unknown-state error -> both rows fail. - runner-recovery-wiring: a snapshot whose reply is dropped is resent and succeeds. Mutation: declare snapshot with DEFAULT_TRAITS (not read-only) -> fails (the lost read goes to status recovery and is not resent). - runner-command-recovery: notAccepted yields reason runner_reply_lost and dispatched unknown. Mutation: same deletion as above -> fails. * fix(apple-runner): restart without resending a command written then lost A connect-shaped failure keeps its restart, but the command is sent again on the restarted runner only with proof the first send could not have run it: the attempt provably wrote nothing (a connect refused before the write, dispatched no), the readiness preflight gave up before the write, or the command is read-only by its runner trait. A mutating command whose first attempt may have written it now fails with reason runner_reply_lost and dispatched unknown after the restart; a restarted runner's journal is empty, so no status answer can prove the first send did not run. A read-only command's restart failure discloses no: a read has no side effect to repeat. dispatch-disclosure.json: ios-runner.transport.written-then-lost keeps unknown with a trigger that says the command is not resent; ios-runner.transport.read-only-written-then-lost is new and discloses no. Tests (runner-lifecycle-dispatch-disclosure, real connect loop over a stubbed fetch and simctl curl exit 28, restart succeeds): - written-then-lost: tap restarts the runner, is not sent on the restarted runner, fails unknown with reason runner_reply_lost. Mutation: make canResendAfterRestart return true -> fails (the tap is replayed and the request succeeds). - read-only-written-then-lost: snapshot is sent again on the restarted runner and its failure discloses no. Mutations: drop the read-only term from canResendAfterRestart -> fails (no resend); drop the read-only branch of discloseRestartDispatch -> fails (unknown). runner-command-retry: four mutating-restart fixtures now carry the proof a real refused connect stamps (dispatched no); without it they would test the removed replay. * test(apple): share the unwritten connect refusal fixture to keep the retry test file within its size ratchet * fix(apple-runner): report the restart transport fact for a read, not no The restart path disclosed `no` for a read-only command whose first send may have run, while the status-recovery path discloses `unknown` for the same read (read_only_completed_without_retained_response, and the notAccepted/unknown-state fallthrough). Both runner paths now report the transport fact: a first attempt that may have written the command is `unknown`, read or mutation. The read-only `no` has one owner, the daemon router seam (request-dispatch-disclosure.ts), which keys it on the registry's recordingEffect `observes-app` for every producer (daemon.read-only-command). A second copy keyed on the runner's own read-only trait reconstructed that rule from a different source of truth, and the two runner paths disagreed. The recycle-budget refusal on the restart path also stops deriving `no` from the read-only trait and uses only whether the first attempt was provably unwritten. The runner still resends a read on the restarted runner (canResendAfterRestart); only the disclosure changes. dispatch-disclosure.json: - ios-runner.transport.read-only-written-then-lost: now `unknown`; the trigger names the router rule that turns it into `no` for the caller. - ios-runner.status.read-only-completed-without-retained-reply: new, `unknown`, driven through the real connect loop and recovery module. It pins the status-recovery read to the same value as the restart read. Mutations: - put back `if (isReadOnlyRunnerCommand(evidence.command)) return discloseDispatch(error, 'no')` in discloseRestartDispatch -> read-only-written-then-lost fails. - set `dispatched: 'no'` on read_only_completed_without_retained_response -> status.read-only-completed-without-retained-reply fails. * test(apple-runner): drive a real runner death through the fake runner runner-recovery-wiring: the notAccepted mutation test no longer scripts a second `ok` for the mutation. Nothing consumed it, and it suggested that a resend was expected. Its comment now says what the test models: status answers notAccepted, the way a runner whose journal did not survive a restart would. The path is status recovery, not the daemon's restart. The fake runner server gains an `exit` reply: it hangs up and stops listening, like a runner process that died mid-command. Before this, a test could only hang up on one request while the same server stayed up. Two new tests use a second server as the restarted runner: - press/fill (runner tap/type): the runner dies on the command. The mutation is sent once, is not sent to the restarted runner, fails with dispatched unknown, and the dead session is invalidated. A readText on the next request is served by the restarted runner. Mutation: declare tap and type with READ_ONLY_TRAITS (the resend trait) -> both rows fail. - get (runner readText): the runner dies mid-read. The connect loop gives up with a possibly-written POST (simctl curl exit 28), the daemon restarts the runner (runner_connect_failed_before_command_send), and the read is resent once and succeeds. Mutation: reduce canResendAfterRestart to `evidence.firstAttemptUnwritten` -> fails (no resend). With the real transport, a mutating command never reaches restartSessionAndRunCommand with a possibly-written first attempt. Mutations are sent by sendRunnerCommandOnce, and only the connect loop (waitForRunner, used for reads and the readiness preflight) stamps runner_connect_refused. So the mutating arm of canResendAfterRestart is covered only by the mocked lifecycle driver (ios-runner.transport.written-then-lost). A dying runner sends a mutation to status recovery, and a failed status probe rethrows the transport error without a runner_reply_lost reason. * fix(apple-runner): name the lost reply when the status probe fails A runner that dies mid-mutation drops the reply, and the status probe that follows fails. The error that reaches the caller was then a bare transport error (`fetch failed`, dispatched unknown), with no typed reason. #3074 requires a reason that names the lost reply, so this path now builds an error with reason runner_reply_lost (the name the restart arm already uses) and recovery status_probe_failed. The error keeps the transport message and details, and the transport error as cause. The session is still invalidated, and the command is still not resent. ADR 0011 gap list: the row ios-runner.transport.written-then-lost proves the rule behind canResendAfterRestart's mutating arm, not a production route. Over the real transport the arm cannot be reached, because mutations go through sendRunnerCommandOnce and only the connect loop raises the restart trigger. Tests: - runner-command-recovery: a failing status probe now asserts reason runner_reply_lost, recovery status_probe_failed, dispatched unknown, and the transport error as cause. - runner-recovery-wiring: press/fill whose runner dies mid-command (real fake-runner transport) now also asserts reason runner_reply_lost. Mutation: drop `reason: RUNNER_REPLY_LOST_REASON` from buildStatusProbeFailedError -> all three fail. * test(apple-runner): hang up on every read attempt without counting the connect loop The read-only lost-response driver scripted 20 hang-ups and a 400 ms timeout. That count depended on the connect loop's retry cadence (RUNNER_CONNECT_ATTEMPT_INTERVAL_MS): with a faster cadence the queue runs out, and the fake answers the next attempt with `ok`. The fake runner server now has a `hangUpAlways` reply that is never consumed, so the read is refused until the loop gives up, however often it retries. The timeout now only bounds how long the loop runs. Mutation: set RUNNER_CONNECT_ATTEMPT_INTERVAL_MS to 10 or 50 (with a 1 ms retry base delay) -> ios-runner.status.read-only-completed-without-retained-reply still passes. * test(apple-runner): pick the fake runner's next reply in one helper The request handler of the fake runner server went over the fallow complexity threshold after hangUpAlways was added. The choice of the next scripted reply is now in takeScriptedResponse. Behavior is unchanged: the apple-runner suite passes (768 tests). * refactor(apple-runner): gate the restart resend at the connect-failure call site A mutation never reaches the connect loop, so the no-resend arm after a restart had no production route. The call site now restarts only when the first attempt provably wrote nothing or the command is read-only, and the arm and its error builder are gone. The written-then-lost row is driven through a fake runner that dies mid-command. A failed status probe keeps a transport reason under transportReason. * test(apple-runner): name runner_reply_lost in the lost-reply test titles * test(apple-runner): the runner read-only set matches the registry's observes-app commands * fix(apple-runner): decide the restart resend in the connect-failure classifier shouldRestartRunnerBeforeCommandSend now takes the command and owns the write-proof rule: a connect-loop failure restarts and resends only when no attempt could have written the command, or the command is read-only. An exit-28 POST that carries dispatched unknown no longer restarts a mutation, whatever call site asks. The call-site guard in executeRunnerCommandAttempt is gone. * refactor(apple-runner): carry the first attempt's dispatch disclosure through the restart restartSessionAndRunCommand takes firstAttemptDispatched: DispatchDisclosure instead of a boolean that three ternaries decoded back into no or unknown. resolveFirstAttemptDispatch computes it once beside isRunnerCommandProvablyUnwritten, and the restart rule reads the same answer. The readiness-preflight call site passes no. * refactor(apple-runner): build every lost-reply failure in one place with one reason Status recovery verdicts now carry either the runner's own answer or the facts of a lost reply. applyRunnerTransportRecovery turns a lost reply into one buildLostReplyError for a mutation, so runner_reply_lost is set on every lost-reply verdict (completed without a retained reply, still in flight, unknown or missing lifecycle, status probe failed, status unavailable) and the transport's reason stays under transportReason. A read keeps its transport error, which the read-only resend loop resends, so it never carries the reason. The four hand-built copies are gone. * fix(apple-runner): key a lost reply on its typed reason, never on transport text * docs(adr): name the lost-reply dispatch-disclosure rows in ADR 0011 * test(apple-runner): pair keyboardReturn with the keyboard enter request that issues it * test(apple-runner): pin the lost-reply branch in the connect-loop mutation test --- contracts/fixtures/dispatch-disclosure.json | 14 +- .../0011-interaction-guarantee-contract.md | 12 + .../runner/__tests__/fake-runner-server.ts | 60 +++-- .../__tests__/runner-command-recovery.test.ts | 40 ++- .../__tests__/runner-command-retry.test.ts | 21 +- .../runner-dispatch-disclosure.test.ts | 58 +++- .../runner-error-classification.test.ts | 48 +++- ...nner-lifecycle-dispatch-disclosure.test.ts | 166 +++++++++++- .../__tests__/runner-recovery-wiring.test.ts | 124 ++++++++- .../__tests__/runner-session-fixtures.ts | 4 + .../src/runner/runner-command-recovery.ts | 254 ++++++++++-------- .../src/runner/runner-error-classification.ts | 49 +++- .../src/runner/runner-lifecycle.ts | 27 +- .../runner-read-only-registry-parity.test.ts | 120 +++++++++ 14 files changed, 806 insertions(+), 191 deletions(-) create mode 100644 src/__tests__/runner-read-only-registry-parity.test.ts diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 81564e2a58..acf40bb0f2 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -256,7 +256,13 @@ { "id": "ios-runner.transport.written-then-lost", "producer": "ios-runner", - "trigger": "a connect attempt posted the command and then timed out (fetch deadline, simctl curl exit 28); the failure keeps the runner_connect_refused restart verdict, and the restart that replays it failed", + "trigger": "a mutating command was posted and the runner process died before replying; the status probe fails, the session is invalidated, and the command is not sent again (details.reason runner_reply_lost)", + "dispatched": "unknown" + }, + { + "id": "ios-runner.transport.read-only-written-then-lost", + "producer": "ios-runner", + "trigger": "a connect attempt posted a read-only command and then timed out (fetch deadline, simctl curl exit 28); the runner is restarted and the read is sent again, and the resend failed; the first send may have run, so the runner reports unknown, and the daemon's read-only rule (daemon.read-only-command) reports no to the caller", "dispatched": "unknown" }, { @@ -289,6 +295,12 @@ "trigger": "transport lost; status probe reports lifecycleState completed without a readable retained reply", "dispatched": "unknown" }, + { + "id": "ios-runner.status.read-only-completed-without-retained-reply", + "producer": "ios-runner", + "trigger": "transport lost on a read-only command; status probe reports lifecycleState completed without a readable retained reply; the runner reports unknown, and the daemon's read-only rule (daemon.read-only-command) reports no to the caller", + "dispatched": "unknown" + }, { "id": "ios-runner.status.accepted", "producer": "ios-runner", diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 6d9572978f..3b0a904140 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -192,6 +192,18 @@ repeat. A producer that runs several device inputs inside one bound operation Each row names its driver file by id prefix, and that file drives the real producer. +The Apple runner does not resend a mutating command whose first send may have +run: a restart resends only a command the first attempt provably did not write, +or a read-only one. A mutation whose reply stays lost (the runner dies +mid-command, or status recovery finds neither a retained result nor a runner +answer) fails with `reason: runner_reply_lost` and `dispatched: unknown` (rows +`ios-runner.transport.written-then-lost`, +`ios-runner.status.completed-without-retained-reply`, +`ios-runner.status.accepted`, `ios-runner.status.started`, +`ios-runner.status.notAccepted`, `ios-runner.status.probe-failed`, and +`ios-runner.status.unavailable`). The `ios-runner.status.failed*` rows carry the +runner's own answer instead. A read keeps its transport error and is resent. + Remaining gaps: a failure before the router's locked scope (session resolution, lock acquisition, lease and daemon-policy admission) never reaches the disclosure and carries no `dispatched`. It sends nothing, but a consumer diff --git a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts index b96acf5634..9b4298605c 100644 --- a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts +++ b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts @@ -13,7 +13,11 @@ import type { AddressInfo } from 'node:net'; export type FakeRunnerResponse = | { kind: 'ok'; data: Record } | { kind: 'runnerError'; code: string; message: string } - | { kind: 'hangUp' }; + | { kind: 'hangUp' } + /** Hangs up on this request and every later one for the command: the entry is never consumed. */ + | { kind: 'hangUpAlways' } + /** Hangs up and stops listening, as a runner process that died mid-command. */ + | { kind: 'exit' }; export type FakeRunnerRequest = { command: string; @@ -44,6 +48,7 @@ export async function startFakeRunnerServer( : Object.fromEntries(Object.entries(script).map(([key, list]) => [key, [...list]])); const remaining = sequential ?? []; const requests: FakeRunnerRequest[] = []; + let stopped: Promise | undefined; const server = http.createServer((req, res) => { let raw = ''; req.on('data', (chunk) => { @@ -53,24 +58,18 @@ export async function startFakeRunnerServer( const body = parseBody(raw); requests.push({ command: String(body.command ?? ''), body }); const next = byCommand - ? (byCommand[String(body.command ?? '')]?.shift() ?? { kind: 'ok' as const, data: {} }) - : remaining.shift(); - if (!next) { - res.statusCode = 500; - res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } })); - return; - } - if (next.kind === 'hangUp') { + ? (takeScriptedResponse(byCommand[String(body.command ?? '')]) ?? { + kind: 'ok' as const, + data: {}, + }) + : takeScriptedResponse(remaining); + if (next?.kind === 'exit') { res.destroy(); + stopped ??= new Promise((resolve) => server.close(() => resolve())); + server.closeAllConnections(); return; } - if (next.kind === 'runnerError') { - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: false, error: { code: next.code, message: next.message } })); - return; - } - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: true, data: next.data })); + writeFakeRunnerResponse(res, next); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); @@ -79,12 +78,41 @@ export async function startFakeRunnerServer( port, requests, close: () => + stopped ?? new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ), }; } +/** The next scripted reply; a `hangUpAlways` entry stays at the head of its queue. */ +function takeScriptedResponse( + queue: FakeRunnerResponse[] | undefined, +): FakeRunnerResponse | undefined { + return queue?.[0]?.kind === 'hangUpAlways' ? queue[0] : queue?.shift(); +} + +function writeFakeRunnerResponse( + res: http.ServerResponse, + next: Exclude | undefined, +): void { + if (!next) { + res.statusCode = 500; + res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } })); + return; + } + if (next.kind === 'hangUp' || next.kind === 'hangUpAlways') { + res.destroy(); + return; + } + res.setHeader('content-type', 'application/json'); + if (next.kind === 'runnerError') { + res.end(JSON.stringify({ ok: false, error: { code: next.code, message: next.message } })); + return; + } + res.end(JSON.stringify({ ok: true, data: next.data })); +} + function parseBody(raw: string): Record { try { const parsed: unknown = JSON.parse(raw); diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index 24f847f53c..8749668dfd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -6,6 +6,7 @@ import { IOS_SIMULATOR } from './device-fixtures.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; import type { RunnerCommand } from '../runner-contract.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; import type { RunnerSession } from '../runner-session.ts'; import { startFakeRunnerServer, @@ -122,22 +123,53 @@ test('an unknown lifecycle state invalidates the session and says so', async () assert.equal(invalidate.mock.calls[0]?.[1], 'transport_error_after_command_send'); }); -test('a failing status probe retains the invalidation and rethrows the transport error', async () => { +test('notAccepted from a restarted runner fails the lost command as unknown, naming the lost reply', async () => { + const { result, invalidate } = await runRecovery({ + script: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], + }); + + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); + assert.equal(invalidate.mock.calls.length, 1); +}); + +test('a failing status probe retains the invalidation and names the lost reply', async () => { const { result, invalidate, transportError } = await runRecovery({ script: [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'status probe exploded' }], + transportError: new AppError('COMMAND_FAILED', 'socket hang up', { reason: 'socket_reset' }), }); - await assert.rejects(result, (error: unknown) => error === transportError); + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.notEqual(error.message, transportError.message); + assert.equal(error.details?.transportError, transportError.message); + assert.equal(error.cause, transportError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportReason, 'socket_reset'); + assert.equal(error.details?.recovery, 'status_probe_failed'); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); assert.equal(invalidate.mock.calls.length, 1); }); -test('a command without an id cannot be probed: invalidate and rethrow', async () => { +test('a command without an id cannot be probed: invalidate and name the lost reply', async () => { const { result, invalidate, transportError } = await runRecovery({ script: [], command: { command: 'tap', x: 10, y: 10 } as RunnerCommand, }); - await assert.rejects(result, (error: unknown) => error === transportError); + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.cause, transportError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.recovery, 'status_recovery_unavailable'); + return true; + }); assert.equal(invalidate.mock.calls.length, 1); assert.equal(server?.requests.length, 0); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 478e65ed06..6ed3423486 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -5,6 +5,7 @@ import { createTestRequestCancellation, makeRunnerSession, runnerConnectFailure, + unwrittenConnectRefusal, } from './runner-session-fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import { Deadline } from '../host.ts'; @@ -49,6 +50,7 @@ vi.mock('../runner-xctestrun.ts', async () => { import { prepareIosRunner, runAppleRunnerCommand } from '../runner-client.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; const requestCancellation = createTestRequestCancellation(); @@ -306,7 +308,7 @@ test('mutating commands restart stale ready sessions when the preflight probe ne mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -329,7 +331,7 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -474,10 +476,9 @@ test('mutating commands keep invalidating when status recovery probe fails', asy await assert.rejects( () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), (error: unknown) => { - // A failed status probe re-throws the original transport error, not the probe's own. assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.message, 'fetch failed'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportError, 'fetch failed'); return true; }, ); @@ -814,7 +815,7 @@ test('mutating commands invalidate the retry session without replaying again', a mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); @@ -970,10 +971,9 @@ test('sequence invalidates the session when the status probe fails', async () => steps: [{ kind: 'tap', x: 1, y: 2 }], }), (error: unknown) => { - // A failed status probe re-throws the original transport error, not the probe's own. assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.message, 'fetch failed'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportError, 'fetch failed'); return true; }, ); @@ -1189,10 +1189,9 @@ test('a later command in the same request cannot pay for a second recycle boot', const requestId = 'req-restart-cap'; const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); - mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockResolvedValueOnce({ message: 'tapped' }); // First command consumes the request's only recycle via restart-and-replay. diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 012476536c..1580de20e7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -10,14 +10,17 @@ import { } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; +import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import { isRetryableRunnerError, isStructuredRunnerFailure, + RUNNER_REPLY_LOST_REASON, } from '../runner-error-classification.ts'; import { runApplePressSeries } from '../runner-sequence.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from '../runner-session.ts'; import { RunnerCommandAccounting } from '../runner-session-types.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; import { startFakeRunnerServer, type FakeRunnerCommandScript, @@ -66,19 +69,33 @@ async function replyFailure(code: string): Promise { ); } -/** The runner hangs up on the command, then answers the status probe with `status`. */ +/** + * The runner hangs up on the command, then answers the status probe with `status`. A read goes + * through the connect loop, which posts again on each attempt, so the runner hangs up on every + * attempt until the loop gives up, and the loop's simctl curl fallback times out after its POST. + * The read's short timeout only bounds how long the loop runs. + */ async function lostResponse( status: FakeRunnerResponse[], command: RunnerCommand = TAP, ): Promise { - server = await startFakeRunnerServer({ tap: [{ kind: 'hangUp' }], status }); + const readOnly = isReadOnlyRunnerCommand(command); + server = await startFakeRunnerServer({ + [command.command]: [{ kind: readOnly ? 'hangUpAlways' : 'hangUp' }], + status, + }); + if (readOnly) { + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 28, stdout: '', stderr: 'curl exited 28' })), + }); + } const session = runnerSession(server.port); const transportError = await executeRunnerCommandWithSession( IOS_SIMULATOR, session, command, undefined, - 5_000, + readOnly ? 400 : 5_000, ).then( () => assert.fail('the fake runner hangs up on the command'), (error: unknown) => asAppError(error, 'COMMAND_FAILED'), @@ -175,6 +192,11 @@ const DRIVERS: Record Promise> = { lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorCode: 'RUNNER_BUSY' })), 'ios-runner.status.completed-without-retained-reply': () => lostResponse(statusReply({ lifecycleState: 'completed' })), + 'ios-runner.status.read-only-completed-without-retained-reply': () => + lostResponse(statusReply({ lifecycleState: 'completed' }), { + command: 'snapshot', + commandId: 'cmd-1', + }), 'ios-runner.status.accepted': () => lostResponse(statusReply({ lifecycleState: 'accepted' })), 'ios-runner.status.started': () => lostResponse(statusReply({ lifecycleState: 'started' })), 'ios-runner.status.notAccepted': () => @@ -185,6 +207,16 @@ const DRIVERS: Record Promise> = { lostResponse([], { command: 'tap', x: 10, y: 10 } as RunnerCommand), }; +/** The rows whose mutation's reply stayed lost: each fails as runner_reply_lost and is not resent. */ +const REPLY_LOST_ROWS: ReadonlySet = new Set([ + 'ios-runner.status.completed-without-retained-reply', + 'ios-runner.status.accepted', + 'ios-runner.status.started', + 'ios-runner.status.notAccepted', + 'ios-runner.status.probe-failed', + 'ios-runner.status.unavailable', +]); + const ROWS = dispatchDisclosureRowsOwnedBy( import.meta.url, fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), @@ -201,7 +233,27 @@ for (const row of ROWS) { await assert.rejects(drive(), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.dispatched, row.dispatched); + assert.equal(error.details?.reason === RUNNER_REPLY_LOST_REASON, REPLY_LOST_ROWS.has(row.id)); return true; }); }); } + +const SNAPSHOT: RunnerCommand = { command: 'snapshot', commandId: 'cmd-1' }; + +test.each([ + ['the status probe fails', [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'down' }]], + ['status answers notAccepted', statusReply({ lifecycleState: 'notAccepted' })], + ['status answers started', statusReply({ lifecycleState: 'started' })], + ['status answers completed with no retained reply', statusReply({ lifecycleState: 'completed' })], +] as const)( + 'a read whose reply is lost when %s keeps the transport error it is resent on', + async (_, status) => { + await assert.rejects(lostResponse([...status], SNAPSHOT), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.notEqual(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(isRetryableRunnerError(error), true); + return true; + }); + }, +); diff --git a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index d5431f1322..f5870a8f21 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts @@ -7,6 +7,7 @@ import { } from '../runner-contract.ts'; import { RUNNER_ERROR_RULES, + RUNNER_REPLY_LOST_REASON, isRetryableRunnerError, isRunnerBusyError, resolveRunnerFatalErrorReason, @@ -15,7 +16,10 @@ import { shouldRestartRunnerBeforeCommandSend, shouldRetryRunnerConnectError, } from '../runner-error-classification.ts'; -import { runnerConnectFailure } from './runner-session-fixtures.ts'; +import { runnerConnectFailure, unwrittenConnectRefusal } from './runner-session-fixtures.ts'; + +const TAP = { command: 'tap' } as const; +const SNAPSHOT = { command: 'snapshot' } as const; function commandFailed(message: string, details?: Record): AppError { return new AppError('COMMAND_FAILED', message, details); @@ -43,6 +47,15 @@ test('transport-shaped failures are retryable', () => { } }); +test('a lost reply keys on its typed reason, never on the transport text it carries', () => { + for (const message of ['fetch failed', 'connect ECONNREFUSED 127.0.0.1:8100', 'socket hang up']) { + const lostReply = commandFailed(message, { reason: RUNNER_REPLY_LOST_REASON }); + assert.equal(isRetryableRunnerError(lostReply), false, message); + assert.equal(shouldRetryRunnerConnectError(lostReply), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(lostReply, TAP), false, message); + } +}); + test('boot-shaped failures are not retryable', () => { assert.equal( isRetryableRunnerError( @@ -165,7 +178,7 @@ test('a deadline on its own earns no recovery verdict', () => { timeoutMs: 45_000, }); assert.equal(isRetryableRunnerError(deadline), false); - assert.equal(shouldRestartRunnerBeforeCommandSend(deadline), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(deadline, SNAPSHOT), false); assert.equal(shouldRestartRunnerAfterReadinessPreflight(deadline), false); assert.equal(shouldRebuildCachedRunnerArtifact(deadline), false); assert.equal(shouldRetryRunnerConnectError(deadline), true); @@ -244,12 +257,16 @@ test('ordinary errors are never session-fatal', () => { // --- restart-before-send axis (shouldRestartRunnerBeforeCommandSend) --- test('a refused connection before send restarts the session', () => { - assert.equal( - shouldRestartRunnerBeforeCommandSend( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ), - true, - ); + assert.equal(shouldRestartRunnerBeforeCommandSend(unwrittenConnectRefusal(), TAP), true); +}); + +test('a connect failure whose POST may have been written restarts only a read', () => { + // simctl curl exit 28: the POST left, then the request timed out. + const writtenThenTimedOut = runnerConnectFailure('runner_connect_refused', undefined, { + dispatched: 'unknown', + }); + assert.equal(shouldRestartRunnerBeforeCommandSend(writtenThenTimedOut, TAP), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(writtenThenTimedOut, SNAPSHOT), true); }); test('an early exit or a foreign transport failure earns no restart before send', () => { @@ -257,8 +274,11 @@ test('an early exit or a foreign transport failure earns no restart before send' 'xcodebuild_exited_early', 'xcodebuild exited early: runner did not accept connection', ); - assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit), false); - assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit, SNAPSHOT), false); + assert.equal( + shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up'), SNAPSHOT), + false, + ); }); // --- typed connect-failure reasons (agent-device's own connect path) --- @@ -269,7 +289,7 @@ test('xcodebuild_exited_early is decided by the typed reason, not the message', assert.equal(isRetryableRunnerError(error), false, message); assert.equal(shouldRetryRunnerConnectError(error), false, message); assert.equal(shouldRebuildCachedRunnerArtifact(error), false, message); - assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, SNAPSHOT), false, message); } // The same words without the reason earn no terminal verdict. const untyped = commandFailed('Runner did not accept connection (xcodebuild exited early)'); @@ -282,12 +302,12 @@ test('runner_connect_refused is decided by the typed reason, not the message', ( assert.equal(isRetryableRunnerError(error), true, message); assert.equal(shouldRetryRunnerConnectError(error), true, message); assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); - assert.equal(shouldRestartRunnerBeforeCommandSend(error), true, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, SNAPSHOT), true, message); } const untyped = commandFailed('Runner did not accept connection'); assert.equal(isRetryableRunnerError(untyped), false); assert.equal(shouldRebuildCachedRunnerArtifact(untyped), false); - assert.equal(shouldRestartRunnerBeforeCommandSend(untyped), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(untyped, SNAPSHOT), false); }); test('runner_endpoint_probe_exhausted is decided by the typed reason, not the message', () => { @@ -295,7 +315,7 @@ test('runner_endpoint_probe_exhausted is decided by the typed reason, not the me const error = runnerConnectFailure('runner_endpoint_probe_exhausted', message); assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); assert.equal(isRetryableRunnerError(error), false, message); - assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, SNAPSHOT), false, message); assert.equal(shouldRetryRunnerConnectError(error), true, message); } assert.equal( diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index 6380c6295e..ac2f49b1a2 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -10,6 +10,7 @@ import { import { IOS_SIMULATOR } from './device-fixtures.ts'; import { createTestRequestCancellation, makeRunnerSession } from './runner-session-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; +import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; // contracts/fixtures/dispatch-disclosure.json, ios-runner pre-send and transport rows: each row // drives runAppleRunnerCommand through the real lifecycle and restart path with the session start @@ -39,6 +40,11 @@ vi.mock('../runner-session.ts', async () => { }); import { runAppleRunnerCommand } from '../runner-client.ts'; +import { + isRetryableRunnerError, + RUNNER_REPLY_LOST_REASON, +} from '../runner-error-classification.ts'; +import type { RunnerCommand } from '../runner-contract.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; import { waitForRunner } from '../runner-startup-transport.ts'; @@ -57,8 +63,12 @@ beforeEach(() => { }); }); -afterEach(() => { +let fakeRunner: FakeRunnerServer | undefined; + +afterEach(async () => { vi.unstubAllGlobals(); + await fakeRunner?.close(); + fakeRunner = undefined; }); async function tap(): Promise { @@ -72,12 +82,9 @@ const readinessPreflightFailure = (): AppError => /** * The first attempt runs the real connect loop against a simulator whose every fetch fails the - * same way and whose simctl curl fallback exits with `curlExitCode`; the restart it earns fails. + * same way and whose simctl curl fallback exits with `curlExitCode`. */ -async function connectLoopThenFailedRestart(transport: { - fetchFailure: () => Error; - curlExitCode: number; -}): Promise { +function stubConnectLoopFailure(transport: { fetchFailure: () => Error; curlExitCode: number }) { vi.stubGlobal( 'fetch', vi.fn(async () => { @@ -91,13 +98,21 @@ async function connectLoopThenFailedRestart(transport: { stderr: `curl exited ${transport.curlExitCode}`, })), }); - mockEnsureRunnerSession - .mockResolvedValueOnce(makeRunnerSession()) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); mockExecuteRunnerCommandWithSession.mockImplementationOnce( async (device, session, command) => await waitForRunner(device, session.port, command, undefined, 400), ); +} + +/** A connect loop that fails as `transport` says, then a restart that fails. */ +async function connectLoopThenFailedRestart(transport: { + fetchFailure: () => Error; + curlExitCode: number; +}): Promise { + stubConnectLoopFailure(transport); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); try { return await tap(); } catch (error) { @@ -107,6 +122,38 @@ async function connectLoopThenFailedRestart(transport: { } } +/** A fetch deadline, then a simctl curl that timed out after its POST: the command may have run. */ +const writtenThenLost = { + fetchFailure: () => new AppError('COMMAND_FAILED', 'Runner command deadline exceeded'), + curlExitCode: 28, +}; + +/** + * `command` is written and its reply lost on the first runner, the runner restarts, and the + * restarted runner answers every command with `restartedAnswer`. + */ +async function writtenThenLostThenRestarted( + command: RunnerCommand, + restartedAnswer: () => Promise>, +): Promise> { + stubConnectLoopFailure(writtenThenLost); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101 })); + mockExecuteRunnerCommandWithSession.mockImplementation(restartedAnswer); + try { + return await runAppleRunnerCommand(IOS_SIMULATOR, command); + } finally { + assert.equal(mockEnsureRunnerSession.mock.calls.length, 2, 'the runner is restarted'); + } +} + +function sendsOnRestartedRunner(): number { + return mockExecuteRunnerCommandWithSession.mock.calls.filter( + ([, session]) => session.port === 8101, + ).length; +} + const DRIVERS: Record Promise> = { 'ios-runner.pre-send.session-start-failed': async () => { mockEnsureRunnerSession.mockRejectedValueOnce( @@ -124,11 +171,47 @@ const DRIVERS: Record Promise> = { }), curlExitCode: 7, }), - 'ios-runner.transport.written-then-lost': () => - connectLoopThenFailedRestart({ - fetchFailure: () => new AppError('COMMAND_FAILED', 'Runner command deadline exceeded'), - curlExitCode: 28, - }), + 'ios-runner.transport.written-then-lost': async () => { + const runnerSession = + await vi.importActual('../runner-session.ts'); + const runner = await startFakeRunnerServer({ tap: [{ kind: 'exit' }] }); + fakeRunner = runner; + // The status probe finds no listener, over fetch or the simctl curl fallback. + const { retryWithPolicy } = appleRunnerTestHost.defaults(); + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 7, stdout: '', stderr: 'curl exited 7' })), + retryWithPolicy: (task, policy, options) => + retryWithPolicy(task, { ...policy, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 }, options), + }); + mockEnsureRunnerSession.mockResolvedValueOnce(makeRunnerSession({ port: runner.port })); + mockExecuteRunnerCommandWithSession.mockImplementation( + runnerSession.executeRunnerCommandWithSession, + ); + try { + return await tap(); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.recovery, 'status_probe_failed'); + assert.equal(error.details?.runnerRestarted, undefined, 'the runner is not restarted'); + throw error; + } finally { + assert.equal(runner.requests.filter((entry) => entry.command === 'tap').length, 1); + assert.deepEqual( + mockInvalidateRunnerSession.mock.calls[0]?.[1], + 'transport_error_after_command_send', + ); + } + }, + 'ios-runner.transport.read-only-written-then-lost': async () => { + try { + return await writtenThenLostThenRestarted({ command: 'snapshot' }, async () => { + throw new AppError('COMMAND_FAILED', 'runner resend failed', { dispatched: 'unknown' }); + }); + } finally { + assert.equal(sendsOnRestartedRunner(), 1, 'the read is sent again'); + } + }, 'ios-runner.pre-send.readiness-preflight-after-restart': async () => { mockEnsureRunnerSession .mockResolvedValueOnce(makeRunnerSession()) @@ -174,6 +257,61 @@ for (const row of ROWS) { }); } +test('a mutation whose connect-loop POST timed out after writing is not restarted or resent', async () => { + stubConnectLoopFailure(writtenThenLost); + mockEnsureRunnerSession.mockResolvedValueOnce(makeRunnerSession()); + mockExecuteRunnerCommandWithSession.mockRejectedValue( + new AppError('COMMAND_FAILED', 'status probe failed'), + ); + await assert.rejects(tap(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerRestarted, undefined); + assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.recovery, 'status_probe_failed'); + return true; + }); + assert.equal(mockEnsureRunnerSession.mock.calls.length, 1, 'the runner is not restarted'); + const taps = mockExecuteRunnerCommandWithSession.mock.calls.filter( + ([, , command]) => command.command === 'tap', + ); + assert.equal(taps.length, 1, 'the tap is sent once'); +}); + +test('a mutation whose reply and status probe both fail on transport text is not resent', async () => { + mockEnsureRunnerSession.mockResolvedValueOnce(makeRunnerSession()); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockRejectedValue(new AppError('COMMAND_FAILED', 'connect ECONNREFUSED 127.0.0.1:8100')); + await assert.rejects(tap(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportError, 'fetch failed'); + assert.equal(isRetryableRunnerError(error), false); + return true; + }); + const taps = mockExecuteRunnerCommandWithSession.mock.calls.filter( + ([, , command]) => command.command === 'tap', + ); + assert.equal(taps.length, 1, 'the tap is sent once'); +}); + +test('a read whose first POST may have been written and whose restart fails discloses unknown', async () => { + stubConnectLoopFailure(writtenThenLost); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); + await assert.rejects( + runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerRestartReason, 'runner_connect_failed_before_command_send'); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }, + ); +}); + test('a plain Error before the exchange is normalized and discloses no', async () => { mockEnsureRunnerSession.mockRejectedValueOnce(new Error('spawn EACCES')); await assert.rejects(tap(), (error: unknown) => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index 5e1c7a4294..9f862ab941 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -10,13 +10,18 @@ import type { RunnerSession } from '../runner-session.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { withAppleRunnerProvider } from '../runner-provider.ts'; import { classifyRunnerReportedError, type RunnerCommand } from '../runner-contract.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; import { createRunnerPhaseBudget, requireRunnerPhaseRemainingMs, resolveExpectedRunnerCacheMetadata, } from '../runner-cache-metadata.ts'; import { captureDiagnostics } from './runner-session-fixtures.ts'; -import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; +import { + startFakeRunnerServer, + type FakeRunnerResponse, + type FakeRunnerServer, +} from './fake-runner-server.ts'; import { resolveRunnerDetachDecision, RunnerCommandAccounting } from '../runner-session-types.ts'; import { requireLifecycleSettlementRows } from './runner-swift-settlement-fixtures.ts'; @@ -40,6 +45,7 @@ import { requireLifecycleSettlementRows } from './runner-swift-settlement-fixtur */ let server: FakeRunnerServer | undefined; +let restartedServer: FakeRunnerServer | undefined; const { ensureRunnerSessionMock, invalidateRunnerSessionMock } = vi.hoisted(() => ({ ensureRunnerSessionMock: vi.fn(), @@ -92,6 +98,8 @@ const LOST_RESPONSE_MUTATION_ROWS = { afterEach(async () => { await server?.close(); server = undefined; + await restartedServer?.close(); + restartedServer = undefined; ensureRunnerSessionMock.mockReset(); invalidateRunnerSessionMock.mockReset(); }); @@ -160,6 +168,120 @@ test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( }, ); +// #3074: `status` answers `notAccepted`, as a runner whose journal did not survive a restart between +// the send and the probe would. That is no proof the first send did not run. +test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( + 'a $acceptanceCommand whose status answers notAccepted fails as runner_reply_lost, sent once', + async ({ runnerCommand, request }) => { + server = await startFakeRunnerServer({ + [runnerCommand]: [{ kind: 'hangUp' }], + status: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], + snapshot: [{ kind: 'ok', data: { nodes: [] } }], + }); + const session = seedSession(server.port); + + await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { ...request }), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + return true; + }); + assert.equal( + server.requests.filter((entry) => entry.command === runnerCommand).length, + 1, + `${runnerCommand} is dispatched once`, + ); + assert.deepEqual(invalidateRunnerSessionMock.mock.calls, [ + [session, 'transport_error_after_command_send'], + ]); + + assert.deepEqual(await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }), { + nodes: [], + }); + assert.equal(ensureRunnerSessionMock.mock.calls.length, 2, 'the next command gets a runner'); + }, +); + +test('a read whose reply is lost is resent and succeeds', async () => { + server = await startFakeRunnerServer({ + snapshot: [{ kind: 'hangUp' }, { kind: 'ok', data: { nodes: [] } }], + status: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], + }); + seedSession(server.port); + // The simctl curl route of a ready simulator could not connect, so it sent nothing. + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 7, stdout: '', stderr: 'curl exited 7' })), + }); + + assert.deepEqual(await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }), { + nodes: [], + }); + assert.equal(server.requests.filter((entry) => entry.command === 'snapshot').length, 2); +}); + +// #3074: the runner process dies mid-command (the fake hangs up and stops listening), and the next +// session the daemon gets is a new runner on a second server. +async function runnerDiesOnCommand( + runnerCommand: string, + restartedScript: Record, +): Promise { + server = await startFakeRunnerServer({ [runnerCommand]: [{ kind: 'exit' }] }); + restartedServer = await startFakeRunnerServer(restartedScript); + ensureRunnerSessionMock + .mockResolvedValueOnce(makeRunnerSession(server.port)) + .mockResolvedValueOnce(makeRunnerSession(restartedServer.port)); + // The simctl curl fallback of the connect loop timed out after its POST. + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 28, stdout: '', stderr: 'curl exited 28' })), + }); + return restartedServer; +} + +function sendsOf(target: FakeRunnerServer, runnerCommand: string): number { + return target.requests.filter((entry) => entry.command === runnerCommand).length; +} + +test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( + 'a $acceptanceCommand whose runner dies mid-command fails as runner_reply_lost, not resent', + async ({ runnerCommand, request }) => { + const restarted = await runnerDiesOnCommand(runnerCommand, { + readText: [{ kind: 'ok', data: { text: 'after' } }], + }); + + await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { ...request }), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + return true; + }); + assert.equal(invalidateRunnerSessionMock.mock.calls.length, 1, 'the dead runner is dropped'); + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'readText', x: 5, y: 5 }), + { text: 'after' }, + ); + assert.equal(sendsOf(server!, runnerCommand), 1, `${runnerCommand} is dispatched once`); + assert.equal(sendsOf(restarted, runnerCommand), 0, `${runnerCommand} is not resent`); + }, +); + +test('a get whose runner dies mid-read is resent once on the restarted runner', async () => { + const restarted = await runnerDiesOnCommand('readText', { + readText: [{ kind: 'ok', data: { text: 'hello' } }], + }); + + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'readText', x: 5, y: 5 }), + { text: 'hello' }, + ); + expect(invalidateRunnerSessionMock).toHaveBeenCalledTimes(1); + expect(invalidateRunnerSessionMock).toHaveBeenCalledWith( + expect.anything(), + 'runner_connect_failed_before_command_send', + ); + assert.equal(sendsOf(server!, 'readText'), 1); + assert.equal(sendsOf(restarted, 'readText'), 1, 'the read is resent once'); +}); + // #2965: an inline `status` probe answers while the command it probes may still be executing, so its // own reply must not clear the mutation's outstanding charge. The handoff verdict is asserted through // `resolveRunnerDetachDecision` — the exact gate `detachRunnerSessionForShutdown` consults. Rows come diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts index d569b32090..ff209e4e55 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -92,6 +92,10 @@ export function runnerConnectFailure( }); } +/** Every connect attempt was refused before a byte was written, so a restart may resend the command. */ +export const unwrittenConnectRefusal = (): AppError => + runnerConnectFailure('runner_connect_refused', undefined, { dispatched: 'no' }); + // Records everything the runner package emits through host.emitDiagnostic / // host.withDiagnosticTimer during `callback` and renders it back as the same // newline-delimited-JSON shape a flushed diagnostics session file holds, so diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 645196db27..837391355b 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -11,30 +11,43 @@ import { type RunnerResponsePayload, } from './runner-contract.ts'; import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; +import { RUNNER_REPLY_LOST_REASON } from './runner-error-classification.ts'; import type { AppleRunnerCommandOptions } from './runner-provider.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from './runner-session.ts'; type RunnerTransportRecovery = | { type: 'recovered'; data: Record; reason: string; lifecycleState?: string } - | { - type: 'skipInvalidation'; - error: AppError; - dispatched: DispatchDisclosure; + | ({ + type: 'skipInvalidation' | 'retainInvalidation'; reason: string; lifecycleState?: string; - } - | { - type: 'retainInvalidation'; - error?: AppError; - dispatched: DispatchDisclosure; - reason: string; - lifecycleState?: string; - }; + } & RunnerRecoveryFailure); + +/** + * What a verdict that recovered no result fails with: the runner's own answer read back from its + * journal, or a reply that stayed lost. + */ +type RunnerRecoveryFailure = + | { runnerAnswer: AppError; dispatched: DispatchDisclosure } + | { lostReply: LostReply }; + +/** What status recovery learned about a command whose reply stayed lost. */ +type LostReply = Readonly<{ + recovery: string; + lifecycleState?: string; + /** + * Never the transport error's message: classification rows match foreign transport text, and a + * lost reply must not read as the retryable transport failure it wraps. + */ + message: string; + hint: string; +}>; type RunnerTransportRecoveryContext = { command: RunnerCommand; session: RunnerSession; transportError: AppError; + options: AppleRunnerCommandOptions; invalidationReason: string; invalidateSession: (session: RunnerSession, reason: string) => Promise; }; @@ -70,26 +83,64 @@ export async function handleRunnerTransportErrorAfterCommandSend(params: { command, session, transportError, + options, invalidationReason, invalidateSession: params.invalidateSession, }); } async function applyRunnerTransportRecovery( - recovery: RunnerTransportRecovery | undefined, + recovery: RunnerTransportRecovery, context: RunnerTransportRecoveryContext, ): Promise> { - if (!recovery) { - return await retainRunnerInvalidation(context, 'status_recovery_unavailable', 'unknown'); - } if (recovery.type === 'recovered') return recoverRunnerResponse(recovery, context); - if (recovery.type === 'skipInvalidation') throw skipRunnerInvalidation(recovery, context); - return await retainRunnerInvalidation( - context, - recovery.reason, - recovery.dispatched, - recovery.lifecycleState, - recovery.error, + const failure = resolveRunnerRecoveryFailure(recovery, context); + if (recovery.type === 'skipInvalidation') { + throw skipRunnerInvalidation(recovery, context, failure); + } + return await retainRunnerInvalidation(recovery, context, failure); +} + +/** + * A lost reply fails a mutation as {@link RUNNER_REPLY_LOST_REASON}: it is not resent. A read keeps + * the transport error, because `runAppleRunnerCommand` resends a read on exactly that error. + */ +function resolveRunnerRecoveryFailure( + failure: RunnerRecoveryFailure, + context: RunnerTransportRecoveryContext, +): AppError { + if ('runnerAnswer' in failure) return discloseDispatch(failure.runnerAnswer, failure.dispatched); + if (isReadOnlyRunnerCommand(context.command)) { + return discloseDispatch(context.transportError, 'unknown'); + } + return discloseDispatch(buildLostReplyError(context, failure.lostReply), 'unknown'); +} + +/** The one shape of a mutation's lost-reply failure; the transport's own reason stays readable. */ +function buildLostReplyError( + context: RunnerTransportRecoveryContext, + lostReply: LostReply, +): AppError { + const { command, transportError, options } = context; + const transportReason = transportError.details?.reason; + return new AppError( + 'COMMAND_FAILED', + lostReply.message, + { + command: command.command, + commandId: command.commandId, + ...(lostReply.lifecycleState === undefined + ? {} + : { lifecycleState: lostReply.lifecycleState }), + reason: RUNNER_REPLY_LOST_REASON, + ...(transportReason === undefined ? {} : { transportReason }), + recovery: lostReply.recovery, + ...readReadinessPreflightRecoveryDetails(transportError), + hint: lostReply.hint, + logPath: options.logPath ?? transportError.details?.logPath, + transportError: transportError.message, + }, + transportError, ); } @@ -109,8 +160,9 @@ function recoverRunnerResponse( } function skipRunnerInvalidation( - recovery: Extract, + recovery: Exclude, context: RunnerTransportRecoveryContext, + failure: AppError, ): AppError { emitRunnerInvalidationDecision({ command: context.command, @@ -120,26 +172,24 @@ function skipRunnerInvalidation( reason: recovery.reason, lifecycleState: recovery.lifecycleState, }); - return discloseDispatch(recovery.error, recovery.dispatched); + return failure; } async function retainRunnerInvalidation( + recovery: Exclude, context: RunnerTransportRecoveryContext, - reason: string, - dispatched: DispatchDisclosure, - lifecycleState?: string, - error?: AppError, + failure: AppError, ): Promise { emitRunnerInvalidationDecision({ command: context.command, session: context.session, transportError: context.transportError, decision: 'retained', - reason, - lifecycleState, + reason: recovery.reason, + lifecycleState: recovery.lifecycleState, }); await context.invalidateSession(context.session, context.invalidationReason); - throw discloseDispatch(error ?? context.transportError, dispatched); + throw failure; } async function tryRecoverRunnerCommandAfterTransportError( @@ -149,8 +199,18 @@ async function tryRecoverRunnerCommandAfterTransportError( transportError: AppError, options: AppleRunnerCommandOptions, signal?: AbortSignal, -): Promise { - if (command.command === 'status' || !command.commandId?.trim()) return undefined; +): Promise { + if (command.command === 'status' || !command.commandId?.trim()) { + return { + type: 'retainInvalidation', + reason: 'status_recovery_unavailable', + lostReply: { + recovery: 'status_recovery_unavailable', + message: lostReplyWithoutStatusMessage(command.command, 'status recovery was unavailable'), + hint: unknownLifecycleStateHint(command.command), + }, + }; + } const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); let status: Record; try { @@ -173,7 +233,15 @@ async function tryRecoverRunnerCommandAfterTransportError( ...readinessPreflight, }, }); - return { type: 'retainInvalidation', reason: 'status_probe_failed', dispatched: 'unknown' }; + return { + type: 'retainInvalidation', + reason: 'status_probe_failed', + lostReply: { + recovery: 'status_probe_failed', + message: lostReplyWithoutStatusMessage(command.command, 'the status probe failed'), + hint: unknownLifecycleStateHint(command.command), + }, + }; } const lifecycleState = typeof status.lifecycleState === 'string' ? status.lifecycleState : ''; @@ -241,9 +309,9 @@ function handleRunnerCommandStatusRecovery( command: RunnerCommand, transportError: AppError, options: AppleRunnerCommandOptions, -): RunnerTransportRecovery | undefined { +): RunnerTransportRecovery { if (lifecycleState === 'completed') { - return handleCompletedRunnerStatus(status, command, transportError, options); + return handleCompletedRunnerStatus(status, command, transportError); } if (lifecycleState === 'failed') { @@ -259,7 +327,13 @@ function handleRunnerCommandStatusRecovery( reason: 'runner_reported_failure', lifecycleState, dispatched: classification.details.dispatched, - error: runnerStatusFailureError(status, classification, command, transportError, options), + runnerAnswer: runnerStatusFailureError( + status, + classification, + command, + transportError, + options, + ), }; } @@ -268,8 +342,16 @@ function handleRunnerCommandStatusRecovery( type: 'skipInvalidation', reason: 'command_still_in_flight', lifecycleState, - dispatched: 'unknown', - error: runnerStatusInFlightError(lifecycleState, command, transportError, options), + lostReply: { + recovery: 'command_still_in_flight', + lifecycleState, + message: `Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`, + hint: inFlightAfterLostResponseHint( + command.command, + lifecycleState, + readReadinessPreflightRecoveryDetails(transportError), + ), + }, }; } @@ -277,21 +359,12 @@ function handleRunnerCommandStatusRecovery( type: 'retainInvalidation', reason: lifecycleState ? 'unknown_lifecycle_state' : 'missing_lifecycle_state', lifecycleState, - dispatched: 'unknown', - error: new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState, - recovery: 'lifecycle_state_not_recoverable', - hint: unknownLifecycleStateHint(command.command), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ), + lostReply: { + recovery: 'lifecycle_state_not_recoverable', + lifecycleState, + message: `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, + hint: unknownLifecycleStateHint(command.command), + }, }; } @@ -299,7 +372,6 @@ function handleCompletedRunnerStatus( status: Record, command: RunnerCommand, transportError: AppError, - options: AppleRunnerCommandOptions, ): RunnerTransportRecovery { const recovered = parseLifecycleResponseJson(status.lifecycleResponseJson); if (recovered) { @@ -310,36 +382,21 @@ function handleCompletedRunnerStatus( lifecycleState: 'completed', }; } - if (isReadOnlyRunnerCommand(command)) { - return { - type: 'skipInvalidation', - error: transportError, - dispatched: 'unknown', - reason: 'read_only_completed_without_retained_response', - lifecycleState: 'completed', - }; - } - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); return { type: 'skipInvalidation', - reason: 'completed_without_retained_response', + reason: isReadOnlyRunnerCommand(command) + ? 'read_only_completed_without_retained_response' + : 'completed_without_retained_response', lifecycleState: 'completed', - dispatched: 'unknown', - error: new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState: 'completed', - recovery: 'completed_without_retained_response', - ...readinessPreflight, - hint: completedWithoutRetainedResponseHint(command.command, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ), + lostReply: { + recovery: 'completed_without_retained_response', + lifecycleState: 'completed', + message: `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, + hint: completedWithoutRetainedResponseHint( + command.command, + readReadinessPreflightRecoveryDetails(transportError), + ), + }, }; } @@ -375,33 +432,6 @@ function runnerStatusFailureError( ); } -function runnerStatusInFlightError( - lifecycleState: string, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): AppError { - if (isReadOnlyRunnerCommand(command)) { - return transportError; - } - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - return new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState, - recovery: 'command_still_in_flight', - ...readinessPreflight, - hint: inFlightAfterLostResponseHint(command.command, lifecycleState, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ); -} - function parseLifecycleResponseJson(value: unknown): Record | undefined { if (typeof value !== 'string' || value.trim().length === 0) return undefined; let payload: RunnerResponsePayload; @@ -473,6 +503,10 @@ function readReadinessPreflightRecoveryDetails( return details; } +function lostReplyWithoutStatusMessage(command: string, statusOutcome: string): string { + return `Runner command "${command}" lost its transport response and ${statusOutcome}, so agent-device invalidated the runner session instead of replaying the command.`; +} + function unknownLifecycleStateHint(command: string): string { return `The runner did not confirm that "${command}" reached a safe terminal state, so agent-device kept the conservative invalidation path. Run snapshot -i before retrying if the UI may have changed.`; } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 772932e398..40431444d5 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -3,6 +3,7 @@ import { isRequestCanceledDetails, type AppErrorCode, type AppErrorDetails, + type DispatchDisclosure, } from '@agent-device/kernel/errors'; import { isCommandTimeoutError, @@ -13,7 +14,9 @@ import { MAIN_THREAD_TIMEOUT_RUNNER_CODE, RUNNER_BUSY_RUNNER_CODE, RUNNER_WEDGED_RUNNER_CODE, + type RunnerCommand, } from './runner-contract.ts'; +import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; export const RUNNER_CACHE_RECOVERY_HINT = 'If runner build products look stale or corrupted, run `pnpm clean:xcuitest` in a local checkout, or remove ~/.agent-device/apple-runner/derived, then retry.'; @@ -42,6 +45,13 @@ export function runnerConnectFailureDetails(reason: RunnerConnectFailureReason): return { runnerConnectFailureReason: reason }; } +/** + * `details.reason` of a mutation whose reply stayed lost: status recovery found no result and no + * runner answer, so nothing proves the command did not run. The command is not resent; the caller + * observes the screen before acting again. A read never carries it, because it is resent. + */ +export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; + type RunnerErrorMatch = { /** Required `AppError.code`; absent = any AppError. */ code?: AppErrorCode; @@ -69,6 +79,8 @@ type RunnerErrorMatch = { details?: RunnerErrorDetailsMatch; }; +const hasRunnerReplyLostReason: RunnerErrorDetailsMatch = (details) => + details.reason === RUNNER_REPLY_LOST_REASON; /** * The runner refused the command before running it while abandoned main-thread work drains (#1105). * A resend keys on this code, never on `details.retriable`: that flag tells a caller's poll to try @@ -221,6 +233,18 @@ const PROFILE_UNUSABLE: RunnerErrorRule['buildFailure'] = { * typed verdict carries the recovery hint a generic connect failure would replace). */ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ + { + // A mutation that may have run: no axis may resend or restart it, whatever text it carries. + reason: RUNNER_REPLY_LOST_REASON, + match: { code: 'COMMAND_FAILED', details: hasRunnerReplyLostReason }, + verdicts: { + retryable: false, + drainResend: false, + connectRetry: false, + restartBeforeSend: false, + restartAfterReadinessPreflight: false, + }, + }, { reason: 'usbmux_device_unattached', match: { code: 'DEVICE_NOT_FOUND', details: hasUsbmuxDeviceUnattached }, @@ -610,11 +634,21 @@ export function resolveRunnerFatalErrorReason(error: unknown): string | undefine } /** - * A connect-shaped failure that surfaced before the command was sent: restart - * the runner session and replay the command, rather than probing a runner - * that never accepted the connection. + * A connect-shaped failure that lets the session restart and resend `command`, rather than probing a + * runner that never accepted the connection. The connect loop posts the command on every attempt, so + * its failure restarts only when no attempt could have written the command, or when the command is + * read-only: a POST that timed out after it was written (simctl curl exit 28) is no proof the + * command did not run, and a mutation is never resent on it. */ -export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { +export function shouldRestartRunnerBeforeCommandSend( + error: unknown, + command: RunnerCommand, +): boolean { + if (!isRunnerConnectRefusal(error)) return false; + return resolveFirstAttemptDispatch(error) === 'no' || isReadOnlyRunnerCommand(command); +} + +function isRunnerConnectRefusal(error: unknown): boolean { return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; } @@ -625,7 +659,7 @@ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { */ export function isRunnerPreSendRefusal(error: unknown): boolean { return ( - (shouldRestartRunnerBeforeCommandSend(error) && isRunnerCommandProvablyUnwritten(error)) || + (isRunnerConnectRefusal(error) && isRunnerCommandProvablyUnwritten(error)) || shouldRestartRunnerAfterReadinessPreflight(error) || isRunnerBusyError(error) ); @@ -641,6 +675,11 @@ export function isRunnerCommandProvablyUnwritten(error: unknown): boolean { return isConnectionRefused(error, 0); } +/** What a failed connect attempt proves about writing the command: `no` only with that proof. */ +export function resolveFirstAttemptDispatch(error: unknown): DispatchDisclosure { + return isRunnerCommandProvablyUnwritten(error) ? 'no' : 'unknown'; +} + function isConnectionRefused(error: unknown, depth: number): boolean { if (depth > 4 || typeof error !== 'object' || error === null) return false; if ((error as { code?: unknown }).code === 'ECONNREFUSED') return true; diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 86cbbae03f..4f5e51b5a0 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -5,6 +5,7 @@ import { discloseDispatch, discloseUnclassifiedDispatch, isRequestCanceledError, + type DispatchDisclosure, } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { ReadinessPhase } from '@agent-device/contracts/wait'; @@ -31,6 +32,7 @@ import { isRetryableRunnerError, isRunnerPreSendRefusal, isStructuredRunnerFailure, + resolveFirstAttemptDispatch, shouldRebuildCachedRunnerArtifact, shouldRestartRunnerAfterReadinessPreflight, shouldRestartRunnerBeforeCommandSend, @@ -351,7 +353,7 @@ async function executeRunnerCommandAttempt( appErr, ); } - if (shouldRestartRunnerBeforeCommandSend(appErr) && session) { + if (shouldRestartRunnerBeforeCommandSend(appErr, command) && session) { assertRunnerRequestActive(options.requestId); return await restartSessionAndRunCommand({ device, @@ -360,7 +362,7 @@ async function executeRunnerCommandAttempt( options, signal, restartReason: 'runner_connect_failed_before_command_send', - firstAttemptUnwritten: isRunnerPreSendRefusal(appErr), + firstAttemptDispatched: resolveFirstAttemptDispatch(appErr), }); } if (session && shouldRestartRunnerAfterReadinessPreflight(appErr)) { @@ -373,7 +375,7 @@ async function executeRunnerCommandAttempt( signal, restartReason: 'runner_readiness_preflight_failed_before_command_send', recoveredDiagnosticPhase: 'ios_runner_readiness_preflight_recovered', - firstAttemptUnwritten: true, + firstAttemptDispatched: 'no', }); } // Status recovery answers "did the command I lost the response to run?". A structured reply @@ -406,10 +408,10 @@ async function restartSessionAndRunCommand(params: { | 'runner_readiness_preflight_failed_before_command_send'; recoveredDiagnosticPhase?: string; /** - * The failed first attempt provably never wrote the command. When it may have, the replay can - * double-send, and no failure of this restart may claim `no`. + * What the failed first attempt disclosed about writing the command. After `unknown`, the replay + * can double-send, and no failure of this restart may claim `no`. */ - firstAttemptUnwritten: boolean; + firstAttemptDispatched: DispatchDisclosure; }): Promise> { const { device, command, options, signal, restartReason } = params; // At most one recycle per request: when the budget is spent, fail fast and KEEP the current @@ -419,7 +421,7 @@ async function restartSessionAndRunCommand(params: { if (!tryBeginRunnerRecycle(recycleKey)) { throw discloseDispatch( buildRunnerRecycleBudgetExhaustedError(command, options), - params.firstAttemptUnwritten ? 'no' : 'unknown', + params.firstAttemptDispatched, ); } await invalidateRunnerSession(params.session, restartReason); @@ -478,7 +480,7 @@ function markRunnerRestartError( error: unknown, params: Pick< Parameters[0], - 'session' | 'command' | 'options' | 'restartReason' | 'firstAttemptUnwritten' + 'session' | 'command' | 'options' | 'restartReason' | 'firstAttemptDispatched' >, restartedSession?: RunnerSession, ): unknown { @@ -500,7 +502,7 @@ function markRunnerRestartError( }, error.cause ?? error, ); - return discloseRestartDispatch(marked, params.firstAttemptUnwritten, restartedSession); + return discloseRestartDispatch(marked, params.firstAttemptDispatched, restartedSession); } /** @@ -510,11 +512,12 @@ function markRunnerRestartError( */ function discloseRestartDispatch( error: AppError, - firstAttemptUnwritten: boolean, + firstAttemptDispatched: DispatchDisclosure, restartedSession: RunnerSession | undefined, ): AppError { - if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); - if (!firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); + if (!restartedSession || firstAttemptDispatched === 'unknown') { + return discloseDispatch(error, firstAttemptDispatched); + } return discloseUnclassifiedDispatch(error, isRunnerPreSendRefusal(error) ? 'no' : 'unknown'); } diff --git a/src/__tests__/runner-read-only-registry-parity.test.ts b/src/__tests__/runner-read-only-registry-parity.test.ts new file mode 100644 index 0000000000..49767e74e9 --- /dev/null +++ b/src/__tests__/runner-read-only-registry-parity.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; +import { fileURLToPath } from 'node:url'; + +type RunnerName = string; +type RunnerCommand = { command: string; action?: string }; +type RunnerTraits = { + isReadOnlyRunnerCommand(command: RunnerCommand): boolean; + RUNNER_COMMAND_TRAITS: Record; +}; + +// The traits module is package-private and exports no subpath; loading it by computed file URL keeps +// this one cross-package parity check from adding a public subpath just for a test. +const { isReadOnlyRunnerCommand, RUNNER_COMMAND_TRAITS } = (await import( + fileURLToPath( + new URL('../../packages/platform-apple/src/runner/runner-command-traits.ts', import.meta.url), + ) +)) as RunnerTraits; + +type RegistryRequest = Readonly<{ command: string; positionals?: readonly string[] }>; + +// The runner owns no mapping to the registry, so this is the one declared place that pairs each +// runner wire command with the registry request that issues it. A runner command with no counterpart +// is runner-internal plumbing: it must be listed in RUNNER_INTERNAL and is checked on its own. +const REGISTRY_COUNTERPART: Record = { + tap: { command: 'press' }, + mouseClick: { command: 'click' }, + longPress: { command: 'longpress' }, + drag: { command: 'swipe' }, + remotePress: { command: 'tv-remote' }, + type: { command: 'type' }, + swipe: { command: 'swipe' }, + scroll: { command: 'scroll' }, + desktopScroll: { command: 'scroll' }, + findText: { command: 'find', positionals: ['text', 'x', 'exists'] }, + querySelector: { command: 'is' }, + readText: { command: 'get' }, + snapshot: { command: 'snapshot' }, + screenshot: { command: 'screenshot' }, + backInApp: { command: 'back' }, + backSystem: { command: 'back' }, + home: { command: 'home' }, + rotate: { command: 'orientation' }, + gesture: { command: 'gesture' }, + appSwitcher: { command: 'app-switcher' }, + actionButton: { command: 'action-button' }, + keyboardDismiss: { command: 'keyboard', positionals: ['dismiss'] }, + keyboardReturn: { command: 'keyboard', positionals: ['enter'] }, + pasteboardWrite: { command: 'clipboard', positionals: ['write', 'x'] }, +}; + +// Runner commands that drive runner or app lifecycle, not a user-visible command, with whether the +// resend gate may treat them as reads. +const RUNNER_INTERNAL: Readonly> = { + status: true, + uptime: true, + appState: true, + gestureViewport: true, + sequence: false, + shutdown: false, + activate: false, + terminate: false, + targetReset: false, +}; + +// `record` observes the app, but starting or stopping the recorder changes runner state, so a +// restart must not resend either; the runner is stricter than the registry here on purpose. +const RUNNER_STRICTER_THAN_REGISTRY: ReadonlySet = new Set([ + 'recordStart', + 'recordStop', +]); + +const RUNNER_COMMANDS = Object.keys(RUNNER_COMMAND_TRAITS); + +test('every runner command is paired with a registry request, internal, declared stricter, or the alert case', () => { + const declared = [ + ...Object.keys(REGISTRY_COUNTERPART), + ...Object.keys(RUNNER_INTERNAL), + ...RUNNER_STRICTER_THAN_REGISTRY, + 'alert', + ].sort(); + assert.deepEqual(declared, [...RUNNER_COMMANDS].sort()); +}); + +test('the runner read-only set equals the runner commands whose registry request observes the app', () => { + for (const [name, request] of Object.entries(REGISTRY_COUNTERPART)) { + const effect = resolveCommandRecordingEffect({ ...request, flags: {} } as never); + assert.equal( + isReadOnlyRunnerCommand({ command: name }), + effect === 'observes-app', + `${name} -> ${request.command}`, + ); + } + for (const [name, readOnly] of Object.entries(RUNNER_INTERNAL)) { + assert.equal(isReadOnlyRunnerCommand({ command: name }), readOnly, name); + } + for (const name of RUNNER_STRICTER_THAN_REGISTRY) { + assert.equal(isReadOnlyRunnerCommand({ command: name }), false, name); + assert.equal( + resolveCommandRecordingEffect({ command: 'record', flags: {} } as never), + 'observes-app', + ); + } +}); + +test('alert actions agree with the registry: only get observes', () => { + for (const action of [undefined, 'get', 'accept', 'dismiss']) { + const effect = resolveCommandRecordingEffect({ + command: 'alert', + positionals: action ? [action] : [], + flags: {}, + }); + assert.equal( + isReadOnlyRunnerCommand({ command: 'alert', action }), + effect === 'observes-app', + String(action), + ); + } +}); From 7d629f75c4d9b5b45f9188e4c157520014ea54db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 11:05:37 +0200 Subject: [PATCH 39/42] test(apple-runner): type the read-only parity rows as the registry request and drop the cast --- src/__tests__/runner-read-only-registry-parity.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/__tests__/runner-read-only-registry-parity.test.ts b/src/__tests__/runner-read-only-registry-parity.test.ts index 49767e74e9..587c1756bf 100644 --- a/src/__tests__/runner-read-only-registry-parity.test.ts +++ b/src/__tests__/runner-read-only-registry-parity.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; +import type { DispatchedCommand } from '@agent-device/contracts/command'; import { fileURLToPath } from 'node:url'; type RunnerName = string; @@ -18,7 +19,8 @@ const { isReadOnlyRunnerCommand, RUNNER_COMMAND_TRAITS } = (await import( ) )) as RunnerTraits; -type RegistryRequest = Readonly<{ command: string; positionals?: readonly string[] }>; +type RegistryRequest = Pick & + Partial>; // The runner owns no mapping to the registry, so this is the one declared place that pairs each // runner wire command with the registry request that issues it. A runner command with no counterpart @@ -85,7 +87,7 @@ test('every runner command is paired with a registry request, internal, declared test('the runner read-only set equals the runner commands whose registry request observes the app', () => { for (const [name, request] of Object.entries(REGISTRY_COUNTERPART)) { - const effect = resolveCommandRecordingEffect({ ...request, flags: {} } as never); + const effect = resolveCommandRecordingEffect({ positionals: [], ...request, flags: {} }); assert.equal( isReadOnlyRunnerCommand({ command: name }), effect === 'observes-app', @@ -98,7 +100,7 @@ test('the runner read-only set equals the runner commands whose registry request for (const name of RUNNER_STRICTER_THAN_REGISTRY) { assert.equal(isReadOnlyRunnerCommand({ command: name }), false, name); assert.equal( - resolveCommandRecordingEffect({ command: 'record', flags: {} } as never), + resolveCommandRecordingEffect({ command: 'record', positionals: [], flags: {} }), 'observes-app', ); } From f04682739d06c039b7c2624f240e675fc5e39bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 11:06:08 +0200 Subject: [PATCH 40/42] fix(apple-runner): keep the transport error's own hint on a mutation lost reply --- .../__tests__/runner-command-retry.test.ts | 21 +++++++++++++++++++ .../src/runner/runner-command-recovery.ts | 9 ++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 6ed3423486..b268d1b60f 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -493,6 +493,27 @@ test('mutating commands keep invalidating when status recovery probe fails', asy }); }); +test('a mutation lost reply keeps the hint its transport error already carries', async () => { + const session = makeRunnerSession({ port: 8100, state: 'ready' }); + + mockEnsureRunnerSession.mockResolvedValueOnce(session); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'fetch failed', { hint: 'Unlock the device and retry.' }), + ) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'status unreachable')); + + await assert.rejects( + () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.hint, 'Unlock the device and retry.'); + return true; + }, + ); +}); + test('mutating commands keep invalidating when status reports an unknown lifecycle state', async () => { const session = makeRunnerSession({ port: 8100, state: 'ready' }); diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 837391355b..69d428b023 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -116,13 +116,18 @@ function resolveRunnerRecoveryFailure( return discloseDispatch(buildLostReplyError(context, failure.lostReply), 'unknown'); } -/** The one shape of a mutation's lost-reply failure; the transport's own reason stays readable. */ +/** + * The one shape of a mutation's lost-reply failure; the transport's own reason stays readable, and + * its own hint wins because it names a cause (a boot failure, an unattached cable) the generic + * lost-reply hint does not. + */ function buildLostReplyError( context: RunnerTransportRecoveryContext, lostReply: LostReply, ): AppError { const { command, transportError, options } = context; const transportReason = transportError.details?.reason; + const transportHint = transportError.details?.hint; return new AppError( 'COMMAND_FAILED', lostReply.message, @@ -136,7 +141,7 @@ function buildLostReplyError( ...(transportReason === undefined ? {} : { transportReason }), recovery: lostReply.recovery, ...readReadinessPreflightRecoveryDetails(transportError), - hint: lostReply.hint, + hint: typeof transportHint === 'string' ? transportHint : lostReply.hint, logPath: options.logPath ?? transportError.details?.logPath, transportError: transportError.message, }, From 34e4e2ec80cdeb2e2a191f34689aa63e27c3358b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 11:07:28 +0200 Subject: [PATCH 41/42] refactor(apple-runner): build the lost-reply message once from the command and cause --- .../src/runner/runner-command-recovery.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 69d428b023..8f950100f0 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -31,17 +31,18 @@ type RunnerRecoveryFailure = | { runnerAnswer: AppError; dispatched: DispatchDisclosure } | { lostReply: LostReply }; -/** What status recovery learned about a command whose reply stayed lost. */ +/** + * What status recovery learned about a command whose reply stayed lost. The message is never the + * transport error's: classification rows match foreign transport text, and a lost reply must not + * read as the retryable failure it wraps. `cause` completes the shared "lost its transport response + * and ..." sentence; `message` is for the outcomes that do not fit it. + */ type LostReply = Readonly<{ recovery: string; lifecycleState?: string; - /** - * Never the transport error's message: classification rows match foreign transport text, and a - * lost reply must not read as the retryable transport failure it wraps. - */ - message: string; hint: string; -}>; +}> & + Readonly<{ cause: string } | { message: string }>; type RunnerTransportRecoveryContext = { command: RunnerCommand; @@ -130,7 +131,7 @@ function buildLostReplyError( const transportHint = transportError.details?.hint; return new AppError( 'COMMAND_FAILED', - lostReply.message, + 'cause' in lostReply ? lostReplyMessage(command.command, lostReply.cause) : lostReply.message, { command: command.command, commandId: command.commandId, @@ -211,7 +212,7 @@ async function tryRecoverRunnerCommandAfterTransportError( reason: 'status_recovery_unavailable', lostReply: { recovery: 'status_recovery_unavailable', - message: lostReplyWithoutStatusMessage(command.command, 'status recovery was unavailable'), + cause: 'status recovery was unavailable', hint: unknownLifecycleStateHint(command.command), }, }; @@ -243,7 +244,7 @@ async function tryRecoverRunnerCommandAfterTransportError( reason: 'status_probe_failed', lostReply: { recovery: 'status_probe_failed', - message: lostReplyWithoutStatusMessage(command.command, 'the status probe failed'), + cause: 'the status probe failed', hint: unknownLifecycleStateHint(command.command), }, }; @@ -367,7 +368,7 @@ function handleRunnerCommandStatusRecovery( lostReply: { recovery: 'lifecycle_state_not_recoverable', lifecycleState, - message: `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, + cause: `lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}`, hint: unknownLifecycleStateHint(command.command), }, }; @@ -508,8 +509,8 @@ function readReadinessPreflightRecoveryDetails( return details; } -function lostReplyWithoutStatusMessage(command: string, statusOutcome: string): string { - return `Runner command "${command}" lost its transport response and ${statusOutcome}, so agent-device invalidated the runner session instead of replaying the command.`; +function lostReplyMessage(command: string, cause: string): string { + return `Runner command "${command}" lost its transport response and ${cause}, so agent-device invalidated the runner session instead of replaying the command.`; } function unknownLifecycleStateHint(command: string): string { From debe50c8b7effbd9ac903d909bde2a159c438510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 11:07:42 +0200 Subject: [PATCH 42/42] docs(wire-compat): say an older peer ignores or rejects an unknown flag --- test/wire-compat/closure-policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/wire-compat/closure-policy.ts b/test/wire-compat/closure-policy.ts index cafce22fd8..64d3dc521e 100644 --- a/test/wire-compat/closure-policy.ts +++ b/test/wire-compat/closure-policy.ts @@ -76,7 +76,7 @@ export const WIRE_CLOSURE_WAIVERS: Readonly> = { 'packages/contracts/src/request-envelope.ts#InternalRequestOptions': 'CLI-side option projection; reaches the peer inside DaemonRequest.input/flags (Record, both listed), and ADR 0006 calls new flags additive.', 'packages/contracts/src/command-flags.ts#CommandFlags': - 'CLI-side flag vocabulary; reaches the peer inside DaemonRequest.flags (Record, listed), and ADR 0006 calls new flags additive. Removing a flag is covered too: flags travel as an untyped record, so an unknown key is dropped.', + 'CLI-side flag vocabulary; reaches the peer inside DaemonRequest.flags (Record, listed), and ADR 0006 calls new flags additive. Removing a flag is covered too: flags travel as an untyped record, so an older peer ignores or rejects an unknown key.', }; export function isExternalWireSpecifier(specifier: string): boolean {