1125 Commits

Author SHA1 Message Date
Michał Pierzchała a55a1a2600 0.20.0 v0.20.0 2026-07-21 20:50:53 +02:00
Michał Pierzchała b1b26f3b6a feat: publish scripts from active sessions (#1357)
* feat: publish scripts from active sessions

* test: cover save-script force retargeting

* fix: address active publication review findings
2026-07-21 20:24:26 +02:00
Michał Pierzchała f087a5938e fix: align Android Maestro gesture dispatch (#1356)
* fix: align Android Maestro gesture dispatch

* fix: preserve Android gesture guarantees
2026-07-21 18:44:08 +02:00
Michał Pierzchała 32ba4b67f4 chore: add FreeRange range-analysis check (#1354)
* chore: add freerange check

* ci: install bun for freerange check

* fix: preserve numeric range contracts

* fix: guard diff overlay geometry

* refactor: isolate diff overlay bounds
2026-07-21 11:49:33 +02:00
Michał Pierzchała 9efe445398 fix: target Maestro scrollUntilVisible container (#1338)
* fix: target Maestro scrollUntilVisible container

* fix: align Maestro scroll viewport selection

* fix: ignore hidden Maestro scroll containers

* fix: derive Android Maestro scroll viewport
2026-07-21 10:54:48 +02:00
Michał Pierzchała c6a8e78768 fix(tvos): skip SpringBoard system-modal probe on tvOS (#1351) (#1353)
tvOS has no SpringBoard (it uses PineBoard/HeadBoard), so probing
com.apple.springboard for blocking system modals raises an XCTest failure
("Application com.apple.springboard is not running") that safely() cannot
trap, terminating the whole runner test. Every snapshot and alert
resolution then failed, and the CLI misreported it as the screen
"overwhelming the accessibility capture".

resolveBlockingSystemModal — the single chokepoint every snapshot/alert
path funnels through — assumed a SpringBoard host always exists. Model that
assumption explicitly with `hasSpringBoardSystemModalHost` and bail to
`.absent` when no host exists, so app-owned alert queries still run. The
fourth probe site (shouldRouteToSpringboardBlockingSystemModal) is already
`#if os(iOS)` and needs no change.

Add tvOS regression tests covering the snapshot and alert-resolution paths.
2026-07-21 07:57:51 +02:00
Michał Pierzchała d9f16de26f docs: retire Maestro compatibility tracker (#1350) 2026-07-20 21:09:11 +02:00
Michał Pierzchała 52e306082f docs: simplify README language (#1352)
* docs: simplify README language

* docs: restore README technical details
2026-07-20 20:25:05 +02:00
Michał Pierzchała b3b9ff59b0 docs: propose active-session script publication (#1347)
* docs: propose active-session replay publication

* docs: address active-session replay review

* docs: align active-session publication on save-script

* docs: qualify destination guard identity

* docs: close active-session publication boundaries
2026-07-20 19:59:52 +02:00
devin-ai-integration[bot] 1f042c3d8d refactor(mcp): extract reference-pin state into tool-ref-pins module (#1344) (#1345)
* refactor(mcp): extract ref-pin state into focused tool-ref-pins module

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): add success-path ref-pin wiring test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(mcp): narrow tool-ref-pins result types and replace as-cast with type guard

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(mcp): remove unnecessary as-casts in tool-ref-pins and command-tools

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(mcp): align ref-pin result handling with #1343 typed result projection

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(mcp): upstream types for ref-pin module (#1345)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(mcp): introduce honest public targetKind-discriminated interaction response contracts

Replace the public AgentDeviceClient return types for press/click/fill/longpress/find with serialized-payload-shaped response data (targetKind, flat ref/selector/x/y, per-command extras) instead of internal runtime result types. The internal PressCommandResult/FillCommandResult/LongPressCommandResult keep their kind/target shapes for the daemon runtime; the public CommandResultMap now points to the new response contracts.

- Add PressCommandResponseData/FillCommandResponseData/LongPressCommandResponseData/FindCommandResponseData in src/contracts/interaction.ts.
- Update CommandResultMap and command-result tests.
- Add client-facing shape tests asserting the public response data discriminates on targetKind and exposes flat identity fields.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(contracts): include cost and iOS Maestro fallback fields in public response contracts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 19:59:35 +02:00
devin-ai-integration[bot] 5a33f8bfc7 feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293) (#1342)
* feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro): validate resolved numeric strings before coercion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro): address review feedback on numeric  resolution

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro-conformance): preserve unresolved ${VAR} numeric tokens in canonical model

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 18:06:04 +02:00
Michał Pierzchała a924f0bb3e fix(mcp): restore result and config parity (#1343)
* fix(mcp): restore result and config parity

* refactor(mcp): narrow parsed input types

* refactor(mcp): simplify parity boundaries

* refactor: narrow validated MCP tool inputs

* refactor: preserve command result types in MCP

* refactor: remove impossible MCP result fallbacks
2026-07-20 16:21:06 +02:00
devin-ai-integration[bot] ab804340c8 feat(maestro): support optional on scrollUntilVisible and extendedWaitUntil (#1291) (#1339)
* feat(maestro): support optional on scrollUntilVisible and extendedWaitUntil

Add optional support at command level and element level for
scrollUntilVisible and extendedWaitUntil. The parser now accepts
optional in both positions and propagates it to the command so the
existing optional-command execution boundary downgrades a timed-out
lookup to a warning and continues the flow.

Update the upstream/076_optional_assertion divergence entry so only
assertTrue remains unsupported, and keep the docs/support matrix in sync.

Closes #1291

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro): reject bare optional selectors, ORed visible/notVisible optionality, and add device differential scenario

- parseMaestroSelectorMapEntries now rejects selectors that contain only
  optional and no real matching criteria, with rejection tests for
  scrollUntilVisible.element, extendedWaitUntil.visible, and .notVisible.
- extendedWaitUntil now rejects simultaneous visible and notVisible conditions
  and derives optionality only from the single condition that will execute.
- Add layer-3 differential flow/scenario optional-warned-scroll-and-wait that
  exercises both command-level and element-level optional on a missing target
  and verifies the flow continues to the final assertion.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(maestro): split parseExtendedWaitUntil to satisfy fallow complexity gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 14:00:57 +02:00
devin-ai-integration[bot] 18b950407c fix(maestro): consume deferred stability before waitForAnimationToEnd (#1326) (#1337)
- Mark waitForAnimationToEnd as requiring a settled predecessor so it
  consumes the stability requirement parked by a preceding tap, swipe, or
  other mutation. Without this, the engine generation advances past the
  deferred requirement and the next tap's settlePending throws a
  generation mismatch.
- Add a unit test that exercises tapOn -> waitForAnimationToEnd -> tapOn
  through the full daemon replay engine.
- Add a layer-3 differential scenario for the same idiom.

Closes #1326

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 12:44:51 +02:00
Michał Pierzchała e7c02a9f4c feat: add advisory device claims (#1329)
* feat: add advisory device claims

* fix: preserve advisory claim ownership

* fix: retain claims after incomplete cleanup

* fix: retain claims across pre-open effects
2026-07-20 11:29:59 +02:00
Michał Pierzchała f4df878a9d docs: improve README onboarding (#1333)
* docs: improve README onboarding

* docs: restore README discovery details

* docs: link README usage proof

* docs: expand README usage proof

* docs: refine README tagline
2026-07-20 10:05:53 +02:00
devin-ai-integration[bot] 0f253f311c Maestro compat: support childOf on assertVisible/assertNotVisible (#1294) (#1334)
* Maestro compat: support childOf on assertVisible/assertNotVisible (#1294)

- Accept childOf at command level in the Maestro IR and parser.
- Thread childOf through the observation condition to the snapshot
  target resolver, reusing the existing ancestor-scoping path.
- Project childOf into the conformance canonical selector so
  upstream/114_child_of_selector matches.
- Remove the stale divergence declaration for 114 and update docs.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: regression-cover assertVisible/assertNotVisible childOf forwarding

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 09:46:05 +02:00
Michał Pierzchała ef118b9d11 ci(test-app): fingerprint-keyed build cache — disk locally, Release artifacts in CI (#1321)
Splits the test app's build caching by context instead of running one remote
cache for both.

Locally, `expo run:*` caches the native build on disk via the
expo-build-disk-cache provider, keyed by the Expo fingerprint. A second run with
no native change reuses the first build; a screen edit never rebuilds, because
Metro serves JS. This is the original ask — "next time we don't build unless
native changes" — and needs no token, no network, and no custom provider.

In CI, test-app-build-cache.yml builds a Release binary per platform when the
fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact
named `fingerprint.<hash>.<platform>`. Release, not dev-client, so the JS bundle
is embedded and a consuming job needs no Metro. setup-fixture-app installs it by
downloading the artifact and refreshing the JS with @expo/repack-app, so keying
on the native-only fingerprint stays correct — a JS-only change reuses the same
native binary in seconds. It falls back to an inline build when no artifact
exists yet, so a caller is never left without an app.

Release removes the sharp edges the dev-client cache needed. Its simulator .app
is universal (x86_64+arm64) rather than the active-arch-only slice a debug build
emits, so no architecture tag. It links against the SDK but loading is gated by
the deployment target, which the fingerprint already covers, so no toolchain
tag. And the CLI only narrows *debug* builds to the device ABI, so a Release APK
spans every ABI without the undocumented --all-arch flag. The artifact name
collapses to fingerprint plus platform.

This deletes build-cache-provider.js entirely — with it goes the custom Expo
provider that had to reach GitHub from inside @expo/cli, and every workaround
that forced: the fetch-nodeshim User-Agent shim, the arch/Xcode identity, the
upload-intent handoff. CI now talks to the artifacts API with plain `gh api`
outside the patched fetch, and locally the disk cache never hits the network.

The fingerprint comes from @expo/fingerprint's own `fingerprint:generate` (no
--platform, matching what @expo/cli hashes). Gitignoring /ios and /android is
what makes it machine-independent: the library asks the VCS whether the platform
markers are ignored and, concluding CNG, skips hashing them — so a developer's
prebuild output and a fresh CI checkout agree.

conformance-differential consumes setup-fixture-app, so it gains
`permissions: actions: read` for the artifact lookup.

The artifact lookup is non-fatal: a query outage leaves the id empty and
falls through to an inline build like a miss does, rather than exiting the
composite under set -e and turning a cache blip into a caller failure.
test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell
against a failing gh and asserts source=build; ci.yml runs it.
2026-07-18 09:36:32 +02:00
Michał Pierzchała d0227998d4 feat: add daemon stop lifecycle (#1323)
* feat: add daemon stop lifecycle

* fix: harden daemon stop cleanup

* fix: fail closed daemon stop cleanup

* fix: bound daemon shutdown lease releases

* fix: await active shutdown lease release

* fix: release provider leases independently on shutdown
2026-07-17 18:21:06 +02:00
Adam Trzciński 12500ddc75 fix(record): finalize active recording on session/daemon teardown (#1325)
* fix(record): finalize active recording on session/daemon teardown

A session torn down while a video recording is still active leaked its
recorder. Neither the session-close teardown (stopBestEffortSessionResources)
nor the daemon-shutdown teardown (teardownSessionResources) stopped an active
recording — only the explicit `record stop` / `test --record-video` finalize
paths did. So when a session ends without a successful explicit stop (e.g. the
daemon is signalled/reaped or replaced mid-suite), the recorder is orphaned.

On the iOS simulator this leaves the detached `simctl io <udid> recordVideo`
child reparented to launchd (PPID 1); because simctl only finalizes the mp4 on
SIGINT, recording.mp4 stays 0 bytes and the single host recording slot stays
held, so later attempts fail with "Host recording is already in progress" and
the runner lease can wedge ("already owned by another agent-device daemon").

Add a best-effort teardown step that routes any still-active recording through
the normal stopActiveRecording path (SIGINT + awaited finalization, all
platforms), wired into both teardown paths. Runner-retention semantics are
preserved by capturing the retain decision before the recording is finalized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(record): budget shutdown for recorder-stop escalation and surface teardown stop failures

Review follow-ups for the teardown recording-finalization fix:

1. Daemon shutdown could still orphan a slow recorder: the per-session
   teardown race gave every session 5s, while the iOS simulator recorder stop
   alone needs up to 11s (5s direct-handle SIGINT wait plus three 2s PID-based
   SIGINT/SIGTERM/SIGKILL retries), so shutdown advanced toward process exit
   exactly when fallback cleanup began. Export the recorder-stop escalation
   budget (IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS) and extend the
   per-session shutdown budget by it when the session has an active recording
   (resolveDaemonSessionTeardownTimeoutMs, resolved before cleanup detaches
   session.recording). The daemon-shutdown session teardown is extracted as
   teardownDaemonSessionForShutdown so the slow direct-handle path is testable.

2. stopSessionRecordingForTeardown previously discarded the typed stop
   failure from stopActiveRecording, so session-close aggregation and daemon
   teardown reported clean cleanup even when the recorder was not finalized.
   It now rethrows the failure as an AppError, which both teardown paths'
   isolated cleanup channels collect as a `recording` cleanup failure while
   later cleanup steps still run.

3. Pin both production routes with regression tests: ordinary `close` and
   daemon session teardown finalize an active iOS simulator recording (SIGINT)
   and surface stop failures, and daemon shutdown lets a dead-direct-handle
   recorder run its full stop escalation instead of timing out at the base
   budget. Removing either wiring call or the budget extension turns them red.

Verified live from source on an iOS simulator: SIGTERM of the daemon
mid-recording finalizes a playable mp4 with no orphaned recordVideo process on
both the fast path and a simulated dead-direct-handle slow path (daemon waits
~11s for the PID-based fallback instead of exiting at 5s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: format daemon-runtime-recording-teardown.test.ts with oxfmt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:06:44 +02:00
Michał Pierzchała a84caa8182 test: give the tap-retry differential a fixture control that forces the retry (#1327)
* test: give the tap-retry differential a fixture control that forces the retry

tap-retry-if-no-change was parked in #1289 for being a coin flip: tapRetries
measured 0 in run 29504440599 and 1 in 29510020718 with no change to the flow or
commit. This re-adds it with a control that holds still, so the retry fires every
run.

The original diagnosis (a dynamic cart badge in the tapped title's subtree) had
the right shape but the wrong scope. maestroSnapshotSignature hashes EVERY node
on screen, not the tapped subtree, so no "static region" of the home screen could
have worked. The actual coin flip is the gesture lab's remote image
(reactnative.dev/img/logo-share.png): whether it lands before or after the tap
decides whether the engine sees "changed" and skips the retry.

So the fixture gets a dedicated inert surface — no state, effects, timers,
images, or pressables — presented as a full-screen modal so iOS detaches the
presenting screen and the tab bar's live badges leave the hierarchy too. On a
real run it is 9 nodes against Settings' 55.

Reached by a launcher on Settings via the Settings TAB, which is a deliberate
choice twice over. A deep link would have kept the launcher out of every other
screen's snapshot, but simctl openurl raises a SpringBoard "Open in app?"
confirmation on iOS 26 even cold, and Maestro's openLink goes through the same
path. And home's "Open settings" button sits below the fold, so reaching it needs
scrollUntilVisible — the engine bug already waived under #1299.

The flow carries no waitForAnimationToEnd: a navigating tap defers a stability
requirement that the next tap settles before resolving its target, so the
baseline signature is already captured on a settled screen. Adding one fails the
flow outright, which is a real engine divergence filed as #1326.

Verified on device (iPhone 17 Pro Max, iOS 26.2, Maestro 2.5.1): 10/10
consecutive differential runs ok, tapRetries [0,0,1] every run — the two
navigating taps correctly do not retry, the inert tap retries exactly once. A
single green run proves nothing here, which is the trap #1300 fell into.

The parking guard in invariants.test.ts is replaced by three guards: the scenario
stays active, carries its tapRetries invariant, and is never waived by a
knownDivergence — a flaky scenario must be fixed, not declared.

Fixes #1300

* chore: gitignore expo prebuild output in the test app

Building the fixture app locally (what .github/actions/setup-fixture-app does in
CI) runs expo prebuild and generates examples/test-app/ios/. It is generated and
untracked but not ignored, so it shows up in git status and a `git commit -a`
would sweep the whole native project in. Same for android/ when building there.

Scoped to examples/test-app, so the repo-root android/ — which holds real tracked
sources like android/ime-helper — is unaffected. Nothing is tracked under either
path today, and the CI fixture-app cache key hashes src/**, app/**, modules/**
and the config/lockfiles, so it does not reference these and is unaffected.
2026-07-17 14:29:04 +02:00
Michał Pierzchała b2b7ddd0d0 chore: gitignore .env files (#1322)
The repository is public and the root `.env` was untracked but matched no
ignore rule, so it showed up in `git status` and any `git add .` or `git add -A`
would have staged it — publishing whatever it holds to the repo and every fork.

It has never been committed to any ref, so this is prevention rather than a
leak; nothing needs rotating.

`.env.*` subsumes the old `examples/test-app/.env.local` entry, which was the
only dotenv rule here and covered a single path. The rules match basenames at
any depth, so every variant is now covered wherever it appears. `!.env.example`
keeps templates trackable; none exist today, but the negation is what makes the
broad `.env.*` safe to add.
2026-07-17 12:39:10 +02:00
devin-ai-integration[bot] 6d99914f49 feat!: remove deprecated gesture duration and rotate velocity inputs (#1218, #1216) (#1315)
* feat!: remove deprecated gesture duration and rotate velocity inputs (#1218, #1216)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: address CI failures - remove dead export, dedupe positional validation, migrate linux-desktop swipe test to pan

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fixup! preserve Maestro swipe endpoint-hold execution profile via internal seam

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(adr): describe Maestro endpoint-hold internal seam in ADR 0013/0015

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: surface Maestro swipe executionProfile in replay trace and assert endpoint-hold in differential

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-17 12:29:41 +02:00
Michał Pierzchała 3fed8acda5 fix(remote): preserve tenant scope for proxy artifact downloads (#1317)
* fix(remote): preserve tenant scope for proxy artifact downloads

* docs(remote): clarify auxiliary tenant precedence

* refactor(remote): carry artifact request scope together

* fix(remote): bump daemon RPC protocol for tenant scope
2026-07-17 11:36:53 +02:00
devin-ai-integration[bot] 10dab65bbf fix(mcp): advertise --no-record on every recordable command's tool schema (#1313)
* fix(mcp): advertise --no-record on every recordable command's tool schema

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(command-descriptor): make recordsSessionAction explicit and exhaustive

- Require recordsSessionAction on every RawCommandDescriptor so new commands must decide their recording behavior at compile time.
- Set the field explicitly on all 73 raw descriptors (26 true, 47 false) and remove replayScopedAction from daemon trait literals.
- Derive daemon replayScopedAction and MCP noRecord schema projection from the single recordsSessionAction classification.
- Keep parity test guard asserting raw descriptors declare the field and that derived replay policy stays in sync.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(command-descriptor): classify recordsSessionAction from actual recording seams and cover every MCP-exposed recordable command

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-17 11:36:33 +02:00
Michał Pierzchała a6711a46cb fix(replay): publish a full-cover system overlay's targets in divergence screen.refs (#1318)
* fix(replay): publish a full-cover system overlay's targets in divergence screen.refs

A fully expanded quick-settings shade left the divergence `screen` available but
empty: 95 captured systemui nodes, 0 refs, no suggestions.

`selectDivergenceScreenRefNodes` drops `collectSettleChromeRefs` nodes before
`isForeignOverlayDismissTarget` can rank them, and the run-level chrome rule
condemns a whole contiguous same-package run when any node in it carries a
status/nav marker. An expanded shade is a SINGLE systemui run: the 4 status-bar
icons it hosts (clock, mobile_combo, mobile_signal, wifi_signal) condemn all 95
nodes, the 23 hittable qs_tile targets included. The run rule assumes the status
bar is its own window — true collapsed, false expanded.

Since #1301 a plain `snapshot` of that same surface returns the tiles (the
`systemSurfaceOnly` carve-out), so the divergence had become strictly NARROWER
than `snapshot` — the invariant `captureDivergenceObservation` documents and the
ADR 0012 decision 4 amendment forbids: the chrome filter must stay a FILTER, never
a narrower scoping.

Fixed in the divergence layer, not in the shared run rule: settle and divergence
genuinely disagree about these nodes and both are right. Settle must keep
stripping the shade run — the markers are literally clock/signal/wifi, the churn
settle exists to ignore, and sparing runs that hold actionable content would let a
ticking clock hold settle awake. The divergence layer owns the violated contract.

Chrome exclusion now falls back to hittable chrome nodes when it would otherwise
empty the pool, mirroring the existing `covered` fallback. The gate is
hittability, not label presence, and both real captures back that: the collapsed
status-bar clock is labeled "7:03" but not hittable (so a chrome-only screen still
publishes nothing), while the expanded shade's clock is hittable.

Live-verified on emulator-5556 (Pixel 9 Pro XL API 37): same repro 0 -> 20 refs
(cap, truncated) — the brightness slider plus 19 hittable tiles, matching the
fixture-driven test exactly. `ANDROID_QS_SHADE_CAPTURE_RAW_NODES` is a real
138-node --raw capture, not hand-authored ids.

* docs(replay): state the chrome-fallback boundary (PR #1318 review)

The fallback fires only when chrome exclusion would empty the pool, mirroring the
`covered` fallback right below it. A marker-bearing overlay over PARTIALLY visible
app content keeps its tiles condemned — the pool is non-empty, so no fallback.
That is deliberate (the full-cover case is the one that strands an agent), but it
was implicit; stating it saves the next investigator a live session.

* refactor(test): move Android capture fixtures into .json files

The fixture module had grown to 1080 lines, of which ~990 were two inlined
device-capture literals — the walkers and their docs were buried under the data
they operate on, and any future capture would bury them further.

Capture fixtures are archived device trees: DATA to be regenerated from a device,
not code to be hand-edited. They now live in `.json` beside the module and load
via `fs` + `import.meta.url` — the existing `test/output-economy` baseline
pattern, which needs no `resolveJsonModule` or import-attributes support to
typecheck, and keeps the trees out of the bundle.

Mechanically extracted from the current exports and verified byte-identical
(`JSON.stringify` before/after match for both fixtures), so this is a pure move:
no fixture data changed. Module drops 1080 -> 109 lines. The leg-E regression test
remains revert-sensitive.

* refactor(test): typecheck the capture fixtures via resolveJsonModule

Reading the fixtures with `fs` + `JSON.parse(...) as RawSnapshotNode[]` bought
nothing over a static import and gave up the one thing that matters for a typed
data fixture: the cast asserts the shape away unchecked, so a fixture that drifts
from `RawSnapshotNode` compiles fine and only surfaces as a confusing test
failure — or silently passes.

`resolveJsonModule` + `import ... with { type: 'json' }` structurally checks each
tree against `RawSnapshotNode[]` at typecheck time instead. Verified: corrupting a
fixture (`hittable: "yes-please"`, a stray field) now fails `tsc` at the export,
where the `fs` version accepted it. Also drops the reader helper and the runtime
read.

The `test/output-economy` precedent I first copied reads baselines through `fs`
for a different reason — those are runtime-compared artifacts, not statically
typed fixtures — so it did not apply here.

tsconfig gains one option; `noEmit` is already set, so it has no output-layout
effect. Fixture data unchanged (byte-identical), leg-E test still
revert-sensitive, full tooling + 1931 tests green.

* refactor(replay): inline the chrome fallback instead of explaining it

`hittableChromeCandidates(nodes)` did not filter chrome — it returned every
hittable node, and was only "chrome candidates" by virtue of its one call site.
The name lied, and a chunk of the 15-line comment above it existed to cover the
gap between the name and the behaviour: exactly the case where the comment is
propping up the code.

Inlined into the same `x.length > 0 ? x : y` idiom the `covered` fallback two
lines below already uses, so the two narrowings now read in parallel. The boundary
that comment spelled out — the fallback fires ONLY when exclusion empties the pool,
so a partial-cover overlay keeps its tiles condemned — is now the ternary itself
rather than a paragraph asserting it.

What survives is the part the code genuinely cannot show: WHY a chrome-only screen
must still publish (the never-narrower-than-`snapshot` contract) and why hittable
is the discriminator. 6 lines, against the 8-line `covered` comment beside it.

No behaviour change: same node set, fixtures untouched, leg-E test still
revert-sensitive, 1931 tests + full tooling green.
2026-07-17 11:35:37 +02:00
Michał Pierzchała a68fcddc42 fix(android): drop the dumpsys scroll-hint probe instead of capping it (#1270) (#1314)
The snapshot helper is the only capture backend and emits `can-scroll-forward`/
`can-scroll-backward` for exactly the nodes Android reports as scrollable. Their
absence therefore means nothing on screen scrolls, not that scroll state is
unknown — so the `dumpsys activity top` probe only ever ran when there was no
scrollable content for it to describe.

#1288 bounded that probe at 1.5s rather than removing it, leaving every capture
of a screen with a scrollable-typed but non-scrollable node (a list short enough
to fit) paying the cap for hints that cannot exist. `dumpsys activity top`
serializes a view dump of every top activity through each app's main thread, so
one busy app stalls the call until Android's own 10s service-dump timeout — the
mechanism behind the 37ms-to-5.9s spread in the issue's evidence.

Hints for genuinely scrollable nodes are unaffected: they come from the parsed
helper attributes, which is where they already came from on every screen that
reports a scroll action.
2026-07-17 09:49:33 +02:00
Michał Pierzchała 8eeed1dded fix: sleep past the settle quiet deadline instead of onto it (#1306) (#1308)
* fix: sleep past the settle quiet deadline instead of onto it (#1306)

runStableCaptureLoop derived its whole cadence from pollMs =
min(300, max(25, quietMs)), so pollMs === quietMs for every quietMs in
[25, 300]. The loop settles once two identical captures span quietMs, and
the gap it measures is exactly one sleep(pollMs) plus capture time — so
across that entire range "settle at capture 2" rode a 0ms margin.

Node decides that margin, not the UI: setTimeout(n) advances Date.now()
by only n-1 in 0.13% of calls idle and 0.63% under load, because libuv
times the sleep on the monotonic loop clock while now() reads the wall
clock. On an undershoot the loop spends a wasted extra capture and poll
before settling.

The sleep is now deadline-aware: while the quiet deadline is further away
than one poll the cadence is unchanged, so changes are still noticed
promptly; once it is within reach, the loop sleeps to just past it. The
capture that decides settled always spans the window.

Effects: --settle-quiet in [25,300] now settles at capture 2 rather than
2-or-3 by coin flip, and the default 500ms window settles at ~502ms
instead of ~600ms with the same 3 captures. No behaviour change beyond
timing; the quiet-window semantics are identical.

* fix: bound the quiet-deadline sleep by the loop's own budget (P2 review)

The review is right: the epsilon could spend the very capture it exists to
land. With quietMs=300 and timeoutMs=301, capture 1 asked for a 302ms sleep,
woke past the 301ms deadline, and the loop exited with one capture — where
the old 300ms cadence settled. waitedMs could exceed timeoutMs too, and the
recovery-reset branch shared the same helper.

stableCaptureDelayMs now takes the deadline and never wakes past it: the loop
only runs again while now < deadline, so where the budget is the tighter
constraint it wakes just inside it and takes the capture the plain cadence
would have taken. The epsilon still applies whenever the budget has room.

The motivating regression test is also strengthened per review, from asserting
the requested delay is > 300 to modelling the defect itself: an injected clock
whose sleep advances now by ms - 1, asserting the observable two-capture
settle. Each test now catches exactly one defect:

  vs main (bd6250212):  undershoot FAILS, boundary passes
  vs addbcc3ae:         undershoot passes, boundary FAILS
  vs this commit:       both pass

Boundary cases added on both sides of the edge: timeoutMs one millisecond past
the quiet window (settles at capture 2, waitedMs within budget), and equal to
it (cannot settle — the window has to elapse inside the budget).

* fix: bound the no-useful-wake sleep too, honouring the deadline contract (P2)

The review is right, and it caught my own contract being violated one branch
below where I wrote it. stableCaptureDelayMs claimed it "never wakes past the
deadline", then exempted the no-useful-wake case: with quietMs=300 and
timeoutMs=300, capture 2 cannot settle, the helper returned a full 25ms
cadence and the loop exited at waitedMs=324 where the old code exited at 300.

That branch now sleeps out only what remains of the budget. The loop is about
to exit either way; overrunning just reports a wait longer than the caller
asked for.

The equal-boundary test asserted rejection only, so it could not see the
overrun — it now asserts the injected clock's elapsed time against the budget
(the timeout error carries captures and nodeCount, but not waitedMs, so the
clock is the honest observable). It fails on 59c936353 with "elapsed 324ms",
exactly as the review predicted.

Each test still catches exactly one defect:

  vs main (bd6250212):  undershoot FAILS
  vs addbcc3ae:         301-budget boundary FAILS
  vs 59c936353:         equal-boundary overrun FAILS
  vs this commit:       all pass

* fix: guarantee the settle poll always buys time, and stop when it cannot (P2)

The review found the real hazard behind the boundary work: not an overrun but
a hot loop. Combine the 1ms-undershooting clock with quietMs=300 and
timeoutMs=301 and capture 2 lands at 299ms; lastUsefulWakeMs is then 1, the
helper asks for 1ms, and the skew eats all of it. The clock never advances,
the deadline never arrives, and the loop captures forever — hammering the
device at exactly the skew this PR exists to absorb.

A delay that buys no time is the bug. Every delay stableCaptureDelayMs returns
is now at least QUIET_DEADLINE_EPSILON_MS, which is what makes the loop
terminate under the modelled undershoot. Where the budget cannot afford a
delay that both progresses and stays inside it, there is no capture left to
place: it returns 0 and the loop stops instead of spinning.

That also subsumes the previous no-useful-wake branch, so the equal-boundary
case now stops at 299ms rather than sleeping out the remainder.

The undershooting test clock now yields to the event loop like a real sleep.
Without that the spin starves the runner on microtasks and the regression
hangs the whole job instead of failing; with it, the reviewed head fails the
new test cleanly with "Test timed out in 6000ms".

Rebased onto 9b5f333a2.

  vs main:         undershoot FAILS
  vs b6261da05:    301-budget boundary FAILS
  vs 8c963fc23:    equal-boundary overrun FAILS
  vs 4f6beb87e:    combined undershoot+301 spin FAILS (timeout)
  vs this commit:  all pass

* fix: use final settle budget under clock skew
2026-07-16 22:16:56 +02:00
Michał Pierzchała 9b5f333a25 fix(cli): deliver --no-record to the daemon (supersedes #1305) (#1311)
#1305 claimed to forward --no-record from "every recordable command reader".
Measured through the real argv -> reader -> client -> daemon chain on its own
merge commit, the flag reached the daemon for ONE command (`open`). It is now
5 of 33 on current main -- `open` plus get/is/find/snapshot, the latter four
only incidentally, because #1303 declared `noRecord` in their metadata.

#1305's fix was inert because it fixed a layer that is not load-bearing. Its
test asserted on `readInputFromCli` output -- an intermediate object two later
layers rebuild from scratch:

  1. `defineExecutableCommand.invoke` runs `metadata.readInput(input)` ->
     `readFieldInput`, which keeps ONLY declared metadata fields plus
     `readCommonInput`'s output. `noRecord` was neither, so it was filtered.
     (`open` survived solely because its metadata declares the field.)
  2. Each `to*Options` projection rebuilds the client options from
     `commonToClientOptions` plus its own named fields; that helper did not
     carry `noRecord` either.

So `--no-record` parsed, was accepted on every command, and was silently
dropped before dispatch -- including on press/click/fill and, per the
maintainer's review, gesture/back/home.

Fixed at the seams the flag must survive, not per reader:
  - `commonInputFromFlags` and `selectionOptionsFromFlags` (the reader layer has
    TWO parallel common helpers -- reader-input shape vs client-options shape --
    so both must carry it; `settings` used only the latter, which is why it was
    the last gap);
  - `readCommonInput` (stop `readFieldInput` filtering it);
  - `commonToClientOptions` (stop `to*Options` dropping it).

Measured after: 33/33 deliver the flag, with zero hand-listed commands.

#1305's `noRecordInputFromFlags` helper and all 13 hand-added call sites are
deleted: they are redundant against the seams, and leaving both would be two
sources of truth for one behavior -- exactly how the next gap breeds.

Preserves the --record asymmetry (ADR 0012 decision 6 amendment): --no-record is
common and rides the common seam; --record stays scoped to snapshot/get/is plus
a dynamically-validated find, on its own narrow helper. Also fixes `get
--record`, dead through the CLI since #1303 for the same re-projection reason
(`toGetOptions` rebuilds its options object), which that PR's daemon-level
scenario could not see.

Coverage is asserted where it is observable, not at the intermediate object:
  - `cli-record-flag-delivery.test.ts` drives real argv and asserts on the
    DAEMON REQUEST for all 32 recordable routes; it fails on reverting either
    seam ("press accepted --no-record but never delivered it to the daemon").
  - `no-record-recorder-routes.test.ts` is a healed-script regression: gesture/
    back/home with --no-record must not land in a written .ad. Reverted, it
    fails with the leaked `gesture "fling" "up" 100 200` line in the script.

A derived `recordsSessionAction` classification + completeness gate follows in a
separate PR: this fixes the 32, that makes a 33rd impossible.
2026-07-16 21:12:33 +02:00
Michał Pierzchała 11a4212f45 fix(android): return meaningful occluding system surfaces instead of failing the snapshot (#1301)
* fix(android): return meaningful occluding system surfaces instead of failing the snapshot (#1253)

The notification shade and quick settings legitimately own the whole screen:
the helper faithfully captures the active system window, but the content
classifier treated the missing application window as a helper failure. Add a
carve-out: an active/focused non-application window carrying meaningful
content is returned as the snapshot, flagged systemSurfaceOnly, with an
agent-facing warning explaining how to reach app content. Sparse or inactive
system windows keep failing with the structured retriable error.

* fix(android): thread system-surface disclosure through selector routes; gate the carve-out on non-chrome content (PR #1301 review)

P1a — selector/find/wait routes no longer lose the disclosure. SnapshotState
gains systemSurfaceOnly, stamped at the one seam where snapshot state and
capture annotations meet (captureSnapshotAttempt), so every consumer —
including session-stored snapshots — inherits it. The disclosure message
moves to a shared module (snapshot/system-surface-disclosure.ts) used by the
capture-runtime warning and a daemon response helper
(handlers/system-surface-disclosure.ts) that appends it to ok-response
warnings and error-response hints. Applied on both found and not-found
outcomes across the public daemon selector routes: read-only find
(exists/wait/get_text/get_attrs), mutating find (matched, unmatched, and
ambiguous), wait (text/selector/ref/stable; pure sleep is exempt), get, and
is.

P1b — the >=3 meaningful-node floor for the system-surface carve-out now
counts only NON-CHROME nodes. The status/nav-bar marker ids from the
settle-chrome classifier (#1198/#1251) move to a shared
contracts/android-system-chrome.ts (core/snapshot-chrome.ts keeps
byte-identical behavior via the shared resource-id predicate), and
classifyAndroidHelperContent excludes chrome-classified resource-ids from
activeSystemSurfaceMeaningfulNodeCount: an active nav bar
(Back + Home + Recents) or status chrome (clock/battery/wifi) is
missing-app-content residue, not a usable shade. Shade/QS fixtures are
unaffected (tile/notification ids are not chrome markers).

P2 — regressions prove the production wiring, not just the classifier:
snapshotAndroid stamps androidSnapshot.systemSurfaceOnly for a helper-backed
shade capture; the capture runtime renders the disclosure warning from the
annotation; daemon regressions pin the disclosure on mutating find (found),
read-only find exists, and wait timeout against a shade capture; classifier
fixtures pin nav-bar and status-chrome windows as unusable.

Live-verified on emulator-5556 with the shade expanded: snapshot -i returns
systemSurfaceOnly true plus the warning; find exists returns found:true plus
the disclosure warning; wait timeout carries the disclosure in its hint.

* fix(daemon): disclose system surfaces on sessionless selector routes; pin disclosure composition (PR #1301 review)

Sessionless find/wait never store the consumed capture on a session record,
so the disclosure read from the session store returned nothing. The selector
capture runtime now reports every consumed snapshot through a shared slot on
the runtime params, and disclosure reads prefer it; the session-store read
remains only as a fallback for pre-captured snapshots. Regressions pin the
sessionless route end-to-end and that the disclosure appends after existing
success warnings and failure hints instead of replacing them.

* fix(daemon): initialize the consumed-snapshot slot on the wait route (PR #1301 review)

dispatchWaitViaRuntime builds its selector runtime directly rather than via
createSelectorRuntime, so sessionless waits had no slot for the capture
runtime to report the consumed snapshot into and lost the system-surface
disclosure. Regressions pin sessionless wait success and timeout, both
asserting no session record exists and the disclosure is present.
2026-07-16 21:08:35 +02:00
Michał Pierzchała d851658421 test: stabilize iOS snapshot timeout regression (#1309) 2026-07-16 21:00:43 +02:00
Michał Pierzchała dd153a6233 fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2) (#1303)
* fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2)

Amends ADR 0012 decision 6: snapshot/get/is/a read-only find are excluded
from a repair-armed heal by default (session.saveScriptBoundary set), never
from ordinary open --save-script authoring recording. wait keeps recording
(flow timing, not observation).

The corrective-read trap (wave-3 E3: the diverged step was itself a get)
means blanket read-exclusion is unsafe, so a new --record flag forces one
action through when the correction is itself a read. --record/--no-record
are mutually exclusive (INVALID_ARGS if both are set) and are plumbed
identically across CLI, the Node client, and MCP.

The exclusion lives at the single daemon-side choke point
(recordActionEntry/isExcludedRepairSegmentObservation), so an excluded read
never grows session.actions.length -- the same counter the existing
record-and-heal resume watermark (describeUnperformedRecordAndHeal) already
checks, so the empty-segment fail-loud guard falls out for free (message
updated to mention --record).

Also fixes a latent bug found along the way: the get/is/find/snapshot CLI
readers never forwarded --no-record/--record into the built request (only
`open` did), so stage 1's "use --no-record" guidance was silently inert via
the CLI.

* test(integration): cover --record with a provider-backed repair-segment scenario (#1271 stage 2)

The progress ratchet (test:integration:progress:check) flagged `record` as an
unclassified public CLI flag. Classifying alone would only trade that failure
for "missing Provider-backed integration workflow flag coverage" -- and the
exclusions bucket is for config/output/transport flags, not behavior flags, so
using it would dodge the ratchet rather than satisfy it.

Adds a focused provider-backed scenario instead, next to the `--no-record`
precedent in android-lifecycle.test.ts. It drives the real request router,
session store, replay runtime, and script writer (only the ADB provider is
faked), and proves the flag's actual purpose end-to-end: inside a repair-armed
`replay --save-script` segment that diverged, the SAME `get text <selector>`
runs twice differing only in `--record`; exactly one line lands in the
committed healed .ad. Also asserts `--record` + `--no-record` is INVALID_ARGS.

Verified the scenario reproduces the bug: with the exclusion neutered it fails
on "a diagnostic read inside a repair segment must not be recorded".

* fix(replay): key the repair-segment exclusion on provenance, scope --record (#1271 review)

Addresses the maintainer review on #1303.

P1 — the exclusion dropped PLANNED reads from the heal. It discriminated by
command class, but the real discriminator is provenance. Replayed plan steps
dispatch through the ordinary request path, so an authored get/is/find step hit
the same recordIfSession -> exclusion path as an interactive read and never
reached session.actions -- and the heal IS session.actions.slice(boundary). A
repaired flow therefore replayed its authored `is visible` assertion and then
silently dropped it from its own healed script: the heal quietly stops checking
what it used to check, which for a 10x-QA-replay suite is the worst failure
mode.

Fix: an explicit provenance marker, not a heuristic. `internal.replayPlanStep`
is stamped by invokeResolvedReplayAction -- the single point every plan step is
dispatched, so it covers annotated and unannotated steps alike. `internal` is
daemon-only (toDaemonRequest never copies it off the wire), so authored
provenance cannot be spoofed; same channel as replayTargetGuard. The rule now
lives once in isInteractiveObservation and both recording call sites consume it,
so the mock fixture uses the production classifier instead of mirroring it.
Planned observations survive automatically -- users never annotate their own .ad
steps.

--record is no longer a common flag: removed from
COMMON_COMMAND_SUPPORTED_FLAG_KEYS, statically scoped via allowedFlags to
snapshot/get/is, and validated dynamically for find (read-only allows; a
mutating find click|fill|focus|type is INVALID_ARGS before any device work,
sharing one isReadOnlyFindAction predicate with the read-only routing so the two
cannot disagree). --no-record stays shared -- it applies to every recordable
command. Removed from `open`, which is never observation-only.

Rebased onto #1304 and dropped the four hand-rolled reader blocks. Split its
helper rather than broadening it: noRecordInputFromFlags (all 13 readers) +
observationRecordInputFromFlags (snapshot/get/is/find only). Two named helpers
over one `allowRecord` policy arg -- the capability is then the helper's NAME, so
a mutating reader physically cannot forward --record, whereas a policy arg would
let a future mutating reader opt in by flipping a literal with no schema change.

ADR-0012 decision 6 now states the provenance rule, not a command-class rule.

The scenario gates the P1: its authored step is a distinguishable `is visible`,
and it fails without the provenance check ("the authored 'is visible' step must
survive the heal").

* test(daemon): pin that wire-supplied `internal` never reaches a daemon request

#1271 stage 2 made `DaemonRequest.internal` semantics-affecting:
`internal.replayPlanStep` decides whether an observation-only command is an
authored plan step (kept in a repair heal) or an out-of-band diagnostic
(excluded). That makes "internal means internally-stamped" worth pinning
rather than leaving to convention.

The invariant already holds, structurally and twice over: the boundary's
`commandRpcParamsSchema` is an allowlist projection emitting only its eight
named fields, and `toDaemonRequest` then builds the request field by field.
Neither can carry `internal` off the wire.

This posts a real JSON-RPC request carrying
`internal: { replayPlanStep: true }` through a loopback server and asserts the
dispatched request has no `internal`. Verified it fails
("a wire-supplied `internal` must never reach the daemon request") when both
allowlists are regressed, so it guards the composite contract instead of
restating one layer.
2026-07-16 20:31:05 +02:00
Michał Pierzchała 856d5d4900 test: replace the hand-typed Maestro fixture with a generated conformance oracle (#1289)
* test: replace the hand-typed Maestro fixture with a generated conformance oracle

Closes #1274.

The old harness (scripts/maestro-conformance*) compared 5 hand-authored flows
against a hand-typed transcription of Maestro 2.5.1's command model. It proved
parser self-consistency, not conformance: all four bug classes that cost #1217
days of live debugging slipped past it by construction, and it verified no
upstream SHAs despite parsing them.

Every expected value here is generated from the pinned upstream artifacts.
dev.mobile:maestro-orchestra:2.5.1 is published on Maven Central, so the harness
runs the real parser and reads the real bytecode — no full Maestro source build.

Layer 1 (parser): a Gradle/Kotlin harness drives the pinned YamlCommandReader
over a corpus of 42 vendored maestro-test flows (sha256-recorded) plus authored
bug-class, coverage, and invalid flows, capturing each parse. The verifier parses
each flow with the live engine and classifies it identical / both-reject /
we-reject / mismatch / we-are-lenient. Every non-identical outcome must be a
declared divergence, so the 17 we-reject entries in expected-divergence.ts are
the mechanical parity backlog (assertTrue, clipboard, travel, killApp, and
option-level gaps) rather than silent drift.

Layer 2 (semantics): ASM reads static-final constants straight from the pinned
bytecode without initializing driver classes (MAX_RETRIES_ALLOWED=3,
SCREENSHOT_DIFF_THRESHOLD=0.005, ANIMATION_TIMEOUT_MS=15000, erase cap, and the
iOS pre-tap gate we intentionally omit), plus the parser-observed 400ms swipe
default. Each is cross-checked against MAESTRO_COMPATIBILITY_PRESETS.

Layer 3 (differential): scheduled device scenarios. Cross-engine comparison is
outcome parity only and says so; finer behavior is asserted engine-side via
invariants over replay-timing.ndjson. Bug class 4's detector — a tap must not
consume the whole settle budget, since a full-budget tap means the stability loop
never latched while the flow still passes — is pure and unit-tested against
synthetic traces; only the device run is scheduled-only.

regenerate.mjs verifies the pinned jar SHA-256s before trusting output and is
byte-deterministic across runs. Layers 1-2 verify in normal CI via node --test
with no Java (the job installs deps: unlike the layering guard it copies, the
verifier parses with the live engine, which imports the `yaml` package).

Acceptance: the four bug classes each have a fixture; every command in
SUPPORTED_MAESTRO_COMMAND_NAMES (the parser's own dispatch table, now exported as
the single source of truth) is corpus-covered or listed unverified; the five
documented deviations are expected-divergence entries.

* fix: address review findings on the conformance oracle

P1 — layer-3 scenarios could never run. They pointed at layer-1 corpus flows,
which exist only to be PARSED: they name a fictional com.example.app and elements
that exist on no device. A device run would have failed before exercising any
runtime behavior, making bug class 4's detector silently vacuous. Layer 3 now has
its own flows under differential/flows/ driving the real fixture app
(examples/test-app, com.callstack.agentdevicelab); the workflow builds and
installs it and hard-fails if it is missing. A test enforces the separation so a
scenario can never point back at the parse corpus.

Nothing else in this repo builds or installs the Expo fixture app, so those steps
are new and unproven. The workflow is therefore dispatch-only: the cron is removed
until a supervised first run proves the path. A nightly job that fails at 05:00
every day teaches nothing.

P2 — layer 3 installed whatever version the online installer served. It now pins
MAESTRO_VERSION from pinned-upstream.json, so layer 3 cannot drift from the
version layers 1-2 claim, and asserts `maestro --version` matches.

P2 — fixture content was not bound to regeneration. CI compared only the embedded
upstream metadata, so a hand edit to a captured command or constant passed: the
transcription failure mode this oracle exists to remove. Two-layer fix, because
per-PR CI must stay Java-free and cannot re-derive:
  - Each fixture now carries a contentHash seal that the verifier recomputes, so
    editing a capture breaks the build. Tamper-evident, and tested by actually
    tampering rather than assuming a hash comparison works.
  - New scheduled conformance-regenerate job re-runs the harness against the
    pinned jars and fails on any byte difference. Forgery cannot survive a real
    re-derivation. This is what makes "generated from upstream" enforced.

P3 — boot-ios-test-simulator requires runtime-version; now passed alongside
preferred-device-name, as the other iOS workflows do.

* tmp: trigger layer-3 differential on this branch to prove the device path

workflow_dispatch cannot run pre-merge (it registers from the default branch), so
this temporary push trigger exists only to execute the never-run device path on
the PR head and capture evidence. Removed before merge.

* fix(ci): install the fixture app unfrozen for the layer-3 device run

First live run of the device path failed at the very first step:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. CI implies --frozen-lockfile and the fixture
app's lockfile is out of sync with its package.json overrides. No CI job has ever
built examples/test-app, so that drift was never surfaced.

* fix: drop --ignore-workspace from test-app:install (defeats #649 security overrides)

The first live run of the layer-3 device path failed at
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and the cause is a real latent bug rather
than a stale lockfile.

#649 moved the fixture app's `overrides` into examples/test-app/pnpm-workspace.yaml
precisely because pnpm only honors overrides from a workspace root — they pin
transitive deps (ws, brace-expansion, xmldom, postcss, uuid, shell-quote) to
versions that clear Dependabot alerts. But `test-app:install` passes
--ignore-workspace, which ignores that very file, so the overrides are dropped
and no longer match the lockfile that has them baked in. It goes unnoticed
locally because interactive installs are not frozen, and no CI job has ever
installed this app.

Dropping --ignore-workspace makes examples/test-app resolve as its own workspace
root (it has its own pnpm-workspace.yaml and is not a member of the repo-root
workspace), so the overrides apply and a frozen install succeeds. Verified both
directions locally: with the flag + --frozen-lockfile reproduces the CI failure;
without it, a frozen install completes and the lockfile's overrides stay intact.

Note the workaround this replaces would have been actively harmful: installing
with --no-frozen-lockfile resolves the mismatch by regenerating the lockfile
WITHOUT the overrides, silently reverting the app to the vulnerable transitive
versions #649 pinned away.

* fix: make layer-3 scenarios prove what they claim, and parse the Maestro version

Run 3 (29497919702) got the whole device path working: Expo build (30m), app
installed, simctl check, pinned Maestro CLI install. Only the version ASSERTION
failed — `maestro --version` prints an analytics banner before the version, and
`tr -d '[:space:]'` mashed banner+version into one string. The CLI was correctly
2.5.1. Match the semver line instead, and set MAESTRO_CLI_NO_ANALYTICS (CI should
not phone home). Verified the parse against the exact CI output: banner and clean
forms both yield 2.5.1, wrong/empty still fail.

tap-retry-if-no-change was vacuous: it tapped a navigating control, so the first
tap always succeeded and retryIfNoChange never ran — it passed while proving
nothing. It now taps the app's non-interactive title so the screen cannot change
and the retry path is forced, and asserts tapRetries >= 1 from the trace
(MaestroRuntimeMetrics already records it per step). A new metricAtLeast invariant
kind carries the assertion; a test reproduces the old vacuity.

percent-swipe no longer claims bug class 1. Truncation vs rounding is a <=1px
delta that no app-observable device outcome can distinguish, so pass/pass could
never back that claim up. The runtime half is instead pinned exactly by a pure
unit test of resolveMaestroCoordinate (it short-circuits on a known viewport, so
no device is needed) — verified to catch the regression by flipping trunc->round,
which turns 3 of 6 tests red. Truncation had no test coverage at all before this.
A test now forbids any device scenario from re-claiming bug class 1.

* fix(ci): pass --maestro and match the fixture app's real UI in layer-3 flows

Run 4 (29500262301) reached the differential itself — build, install, simctl
check and the pinned Maestro 2.5.1 verification all passed — and surfaced two
real bugs, both mine:

1. The runner invoked `agent-device test <flow>` without --maestro, so every
   scenario failed with "test does not support this file type". The repo's own
   scripts/run-test-app-maestro-suite.mjs passes it; the flag is what routes a
   .yaml through the Maestro compat engine.

2. settle-after-tap and percent-swipe assumed home-open-form is on screen at
   launch. It is not: real Maestro reported "Element not found: home-open-form",
   and the app's own helper flow scrolls it into view first. settle-after-tap now
   scrolls before tapping, mirroring that helper; percent-swipe no longer
   navigates at all and swipes the scrollable home screen, so it tests the
   conversion and nothing else.

The remaining two flows already reported maestro=pass, so only the agent-device
invocation was wrong for those. Note the settle invariant correctly reported
"no-data: no completed tapOn steps" and FAILED rather than passing — a detector
that cannot run is a failure, as intended.

* feat: declare layer-3 divergences and schedule the differential

Layer 3 ran both engines for the first time (29504440599) and immediately found a
real engine bug. Blocking the measurement instrument on repairing what it just
measured inverts the dependency, so layer 3 now gets the contract layer 1 already
had: every divergence is a decision on the record.

Adds `knownDivergence: { reason, tracking }` to the scenario type — the layer-3
twin of FLOW_DIVERGENCES. A declared divergence keeps the run green; only
UNDECLARED ones fail. Two rules stop that from rotting, both enforced
mechanically rather than by prose discipline:

  - `tracking` is required and must be a real issue URL (run.test.ts), because a
    declaration with nothing behind it is how "temporarily expected" becomes
    permanent without anyone deciding to.
  - a stale declaration FAILS: if a declared-divergent scenario starts passing,
    the run goes red until the declaration is removed. The fix PR must delete it,
    and the differential then enforces the gap stays closed — the oracle is the
    acceptance test for its own findings.

Declared:
  - settle-after-tap  -> #1299. Our scrollUntilVisible times out finding
    home-open-form where Maestro 2.5.1 scrolls to it and passes. Real engine
    correctness bug in an advertised command, found by this differential. Blocks
    bug class 4's device detector until fixed.
  - tap-retry-if-no-change -> #1300. The invariant caught the scenario being
    vacuous: both engines pass but tapRetries was 0, so retryIfNoChange never
    ran. Needs an inert fixture control; a scenario defect, not an engine one.

Proven green on both engines and enforced now: percent-swipe,
optional-warned-not-failed — the latter is real device-verified warned-vs-failed
parity.

With declarations in place the differential is green, so the schedule goes in
(cron 05:00) per #1274. A green run still prints what it is not proving.

* fix: park the flaky retry scenario instead of declaring it a divergence

Run 29510020718 fired the stale-declaration guard on its first outing and caught
my own mistake. tap-retry-if-no-change measured tapRetries=0 in run
29504440599 and tapRetries=1 in 29510020718 — same flow, same commit. So it is
not vacuous as #1300 originally claimed: it is NON-DETERMINISTIC. The tap
sometimes holds the hierarchy signature still and sometimes does not, because the
fixture home screen carries live content.

That exposes a real limit of the mechanism added in the previous commit:
knownDivergence assumes the divergence REPRODUCES. A declared-but-flaky scenario
flips between known-divergence (green) and stale-declaration (red) at random — a
coin-flip scheduled job, which is worse than no scenario because it teaches
people to ignore the differential.

So the scenario is parked, not declared. The flow and the tapRetries invariant
stay implemented and unit-tested, so the fix PR only re-adds the scenario once
the fixture has an inert control. retryIfNoChange therefore has NO device
coverage right now — tracked in #1300 and stated plainly rather than disguised by
a green run. A test keeps it out of the active set until then.

#1300 updated with the corrected diagnosis and both runs' evidence.

Active differential: settle-after-tap (declared divergence, #1299), percent-swipe
and optional-warned-not-failed (both enforced, pass/pass on real devices).

* fix: make a knownDivergence waiver cover exactly one failure, not any failure

P1 from re-review, and a real flaw: the code did not do what its own comment
claimed. runScenario() collapsed every unexpected outcome and every invariant
failure into `misbehaved`, then turned ANY of them green if the scenario carried
a declaration. So while the #1299 scrollUntilVisible waiver is open, upstream
Maestro could start failing too — or a different invariant could break — and the
scheduled job would still report known-divergence and pass. A waiver for one bug
was silently amnesty for the next. That is the exact failure this oracle exists
to prevent, committed one commit after building the guard against it.

knownDivergence now requires an `expected` signature: both engines' outcomes plus
each declared invariant's status. The runner matches it exactly —
  - matches            -> known-divergence (green, tracked)
  - misbehaves differently -> failed (red): not the failure the waiver covers
  - stops misbehaving  -> stale-declaration (red): remove the declaration
#1299's signature pins what runs 29504440599/29510020718 actually observed:
maestro=pass, agent-device=fail, settle invariant no-data.

Tests prove unrelated failures stay red under an open waiver: upstream also
failing, our engine unexpectedly passing, a different invariant status, and a new
invariant appearing are each NOT covered. A signature where both engines pass is
rejected outright as describing no divergence.

Also retains replay-timing.ndjson as a run artifact (review evidence note): the
invariants are computed from that trace, so a report saying "tapRetries was 0"
cannot be audited once the runner is gone without it.

* perf(ci): cache the fixture app build for the layer-3 differential

The differential job took ~30 minutes, of which 1331s (22 min, 79%) was building
the Expo fixture app and only 347s was the differential itself — rebuilt from
scratch on every run for an app that changes almost never.

Cache the built .app, keyed on everything that can change the binary: the app's
sources, native config, dependency graph, the build step itself, the iOS runtime,
and the Xcode version. Mirrors the existing setup-apple-replay prebuilt-runner
cache (same action pin, same Xcode-key + source-hash shape).

On a hit the build is skipped entirely and the bundle is installed straight onto
the booted simulator (~seconds), taking the job to roughly 8 minutes. On a miss
it falls back to exactly the previous behaviour and repopulates, so the worst
case is unchanged. The existing simctl verification still gates both paths, so a
bad cache cannot produce a vacuous green: if the app is not installed, the job
fails loudly rather than running scenarios against nothing.

Note the first run after this lands is necessarily a miss.

* refactor(ci): extract setup-fixture-app so any job can use the cached app

The fixture-app build + cache was inline in the differential workflow, so nothing
else could reach it. Extracted to a composite action mirroring
setup-apple-replay, because the capability is what #320 has been missing: it
wants replay coverage moved off Apple system apps onto a controlled fixture with
stable ids, and that fixture (examples/test-app) already exists — CI just had no
way to build and install it.

The cache is genuinely shared. GitHub caches are per-repository and readable
across workflows, and a run restores from its own branch or the default branch,
so once a run on main populates it every workflow gets the hit and only the first
one pays the ~22 minutes. The key is computed inside the action from a fixed
input list and deliberately contains nothing caller-specific — folding a caller's
workflow path into it would silently unshare the cache.

Also removes a duplication risk: the action reads the bundle id from the built
app's Info.plist rather than hardcoding it, so it cannot drift from what was
actually built, and it fails loudly if the app is not installed. The conformance
workflow keeps its own narrower assertion — that the installed id is the one its
scenarios target — since that is its concern, not the action's.

Usage:
  - uses: ./.github/actions/setup-fixture-app
    with:
      runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
  # outputs: app-path, app-id, cache-hit

* chore(ci): remove the temporary branch push trigger

Run 29519848340 on this head executed both engines against the real fixture app
and came back green, so the trigger that existed only to prove the never-run
device path has done its job.

Merged config is now cron (05:00) + workflow_dispatch, as required by #1274.

  known-divergence  settle-after-tap  maestro=pass agent-device=fail (#1299)
  ok                percent-swipe              maestro=pass agent-device=pass
  ok                optional-warned-not-failed maestro=pass agent-device=pass

This commit will not itself trigger a run: GitHub evaluates triggers at the
pushed commit, and the push trigger is gone in it.
2026-07-16 20:22:28 +02:00
Michał Pierzchała c245906b70 fix(cli): forward --no-record from every recordable command reader (#1304) (#1305)
`--no-record` is accepted on every command (its key is in
COMMON_COMMAND_SUPPORTED_FLAG_KEYS, which seeds every command schema's
supportedFlags) and is documented as "Do not record this action", but no
interaction/capture reader forwarded it into the options object the daemon
request is built from. The flag parsed, then vanished.

The rest of the chain was already wired: command-flags.ts maps
options.noRecord onto the request, and recordActionEntry reads
entry.flags?.noRecord to skip the action. Only the reader step was missing,
so the documented behaviour never happened for any of press, click, fill,
longpress, swipe, focus, type, scroll, get, is, find, snapshot, or wait.
`app` was the sole command that forwarded it.

Measured through the real argv path (parseArgs -> readInputFromCli), before
this change: 13/13 commands accept `--no-record`, 0/13 reach the options
object. After: 13/13.

Fixed at the shared seam rather than per reader. noRecord is a common flag,
so it gets a recordControlInputFromFlags() helper next to the existing
settleInputFromFlags/repeatedInputFromFlags group helpers, and each
recordable reader spreads it. A future reader picks it up by spreading one
helper instead of re-deriving a flag it never names.

The regression test covers all 13 recordable commands and fails without the
fix ("press dropped --no-record").
2026-07-16 20:16:21 +02:00
Michał Pierzchała a6789d086b docs: clarify keyboard dismiss fallbacks (#1302)
* docs: guide iOS keyboard blur fallback

* docs: simplify iOS keyboard blur guidance

* docs: clarify keyboard dismiss fallbacks

* fix: make keyboard fallback skillgym cases decisive
2026-07-16 20:15:47 +02:00
Michał Pierzchała 95f838f514 test: stop pinning settle capture counts against a wall-clock loop (#1307)
* test: model the UI in settle-observation fixtures instead of a capture count

settle-observation's "contention flakes" were a zero-margin comparison
meeting a 1ms clock skew, not generic load.

runStableCaptureLoop derives pollMs = min(300, max(25, quietMs)), so the
test's settleQuietMs: 25 made pollMs === quietMs, and the settle check
(one sleep(25) plus capture time, against >= 25) a 0ms margin. Node's
setTimeout(25) advances Date.now() by only 24ms in 0.13% of calls idle
and 0.63% under load, because libuv's timers and Date.now() read
different clocks. On a 24 the loop takes a third capture that the
transcript never scripted, and settle's best-effort catch reports the
resulting throw as settled: false.

The fixtures now model the surface rather than the runner's speed: a
quiet UI serves the settled tree to every capture, a busy one a fresh
tree per capture, via new transcript `repeat` entries and result
factories. The snapshot-floor economy guard survives as a bound (2-3
captures), and the follow-up's "no fresh capture" cost — previously
implied by the exact transcript — is now asserted directly.

Production is untouched: the same zero margin only costs a wasted extra
capture and poll there, filed separately as #1306.

* test: stop pinning the settle capture count in the iOS contract scenario too

A sweep for the same bug class found direct-ios-selector's settleObservation
scenario carrying the identical 0ms margin: settleQuietMs: 25 (so pollMs ===
quietMs) against a consume-once transcript scripting exactly two settle
captures, with no injected clock. It has not lost the coin flip in CI yet,
but it fails the same way when it does — a third capture finds no entry and
settle's best-effort catch reports settled: false.

Same fix: the fixture models a quiet UI (every settle capture sees the same
tree) instead of scripting how many captures fit in a wall-clock window.

The rest of the sweep was clean. The other contract scenarios and the
interaction runtime tests already use clamped mocks that repeat the last
snapshot, so any capture count is tolerated; the fake-clock tests are correct
to pin exact counts.

* style: oxfmt quietRunnerSnapshotEntry signature

* test: make one-shot-outranks-repeat a real transcript rule (P2 review)

The review is right: `one-shot entries still outrank a repeat entry` asserted
a guarantee the lookup did not provide. It passed only because the one-shot
happened to be declared first — unordered lookup took the first match, so a
repeat declared ahead of a matching one-shot shadowed it forever and left it
permanently unconsumed. Both failure modes reproduce; the reverse-order test
added here fails on the previous implementation.

Unordered lookup now searches matching one-shots before repeats, so
outranking holds whatever the declaration order. A repeat is documented as
its command's fallback.

Ordered transcripts now reject repeats at construction: ordered lookup only
ever reads the head, so a repeat there never advances and strands every entry
behind it. Refusing the combination beats failing later as a confusing
"Provider command mismatch".

Coverage added for both: reverse declaration order, and ordered + repeat.
2026-07-16 20:13:07 +02:00
Michał Pierzchała 117f78107e feat: add direct Limrun provider runtime (#1278)
* feat: add direct Limrun cloud runtime

* refactor: reuse Android provider runtime for Limrun

* refactor: pass runner context to provider runtimes

* fix: remove Android gesture swipe fallback

* fix: reconcile Limrun direct runtime with main

* refactor: compose Android provider interactors in core

* fix: satisfy packaged Limrun runtime checks

* perf: load Limrun provider runtime on demand

* docs: document Limrun device cloud flow

* refactor: reuse Android reverse provider for Limrun

* fix: isolate provider-owned iOS sessions

* fix: preserve provider runtime boundaries

* refactor: split close repair lifecycle

* fix: reject unavailable provider leases

* fix: reconcile provider runtime review feedback

* test: stabilize alert deadline smoke assertion

* fix: recover expired provider leases

* fix: limit Limrun to remote simulators

* fix: make Limrun provider cleanup durable

* test: cover Limrun connect through CLI

* fix: make provider expiry recovery durable

* refactor: remove Limrun compatibility cleanup

* fix: release live provider leases on expiry
2026-07-16 19:26:20 +02:00
Michał Pierzchała 1fdbf80c32 fix(replay): retarget identity-empty press containers to their labeled descendant (#1280) (#1286)
* refactor(replay): share the id-demotion predicate via target-identity-node

Extract session-target-evidence.ts's demoteNonUniqueId into a shared
demoteNonUniqueLocalIdentity (target-identity-node.ts), and export
build.ts's normalizeSelectorText. Both become shared building blocks a
third call site (#1280's press-retarget identity-empty check) reuses
instead of re-deriving the id-demotion rule and value/text normalization
a third way. No behavior change.

* fix(replay): retarget identity-empty press containers to their labeled descendant (#1280)

Android list-row presses target a clickable container (role="linearlayout")
with no id, no label, no value/text — its title lives on a labeled
descendant (the android:id/title TextView, whose own id #1272 already
demotes for being non-unique). The container's identity is role-only and
shared by every row, so replay disambiguates positionally and mis-binds
under reorder (measured matchCount 12, 20/20 identity-mismatch).

Retarget at record time: when a press/click/fill resolves to an
identity-empty container (rule 1), substitute its first labeled descendant
in document order (rule 2), but only when the container's subtree has no
other interactive/hittable node (rule 3, fail-closed — a trailing
Switch/Checkbox must not retarget, since a tap at the descendant's center
vs the container's could land on different controls). Guard-blocked or
label-less subtrees record exactly as today.

Implemented once at the single recording choke point
(describeResolvedInteractionNode, resolution.ts): the returned node feeds
BOTH buildSelectorChainForNode's chain and (downstream, via
recordedTargetCapture) computeTargetEvidence, so the two writers can never
half-retarget. Recording-time only — resolveSelectorChain and live
press/fill dispatch are unchanged; the tap point is already fixed against
the original container before this substitution runs.

Adds an ADR 0012 decision 3 amendment (mirroring #1269's), a
press-retarget unit/guard/cross-invariant suite (including an RN FlatList
iOS parity fixture), and a reorder+insert e2e proving the retargeted
recording rebinds by role+label where the un-retargeted container
recording refuses.

* fix(replay): keep response hittability on the dispatched container, not the retargeted descendant

Review blocker on #1286 (flag 1 adjudicated): describeResolvedInteractionNode
was computing describeNonHittableTarget from the retargeted descendant, so
every retargeted press on a non-hittable title TextView would emit a false
`targetHittable: false` + misleading hint on the exact happy path the fix
serves — a live-response regression violating the design's recording-time-only
rule.

Split the fields by what they are FOR: recording-coupled fields (node as
evidence source, selectorChain, refLabel — they become the .ad step) keep
following the retargeted descendant; the response-semantic
describeNonHittableTarget (targetHittable + hint) reverts to the original
node, describing what was actually dispatched. Documented in the function
comment and the ADR amendment; new load-bearing test (fails against the
pre-fix line): a hittable container with a non-hittable labeled child presses
with no targetHittable/hint while chain/evidence/refLabel belong to the
descendant.

* fix(replay): carry the press retarget on a recording-only side channel; harden the guard (#1280 re-review)

Maintainer re-review corrections, four findings:

P1a (side channel): the runtime response is now entirely container-based —
node, selectorChain, refLabel, point, resolution disclosure, hittability all
describe the dispatched container, restoring the response-identity contract.
The retarget travels as an optional recordingTarget {node, selectorChain,
refLabel} on the runtime result (contracts/interaction.ts), consumed only at
the recording boundary (interaction-touch-response.ts): the recorded action
entry — the .ad writer's result.selectorChain source — takes the descendant
chain/ref-label and recordedTargetCapture feeds the descendant node to
computeTargetEvidence, while both wire payloads keep container materials.
Daemon-route regression proves response container-based + recorded entry,
target-v1 evidence, and the physically written .ad line descendant-based.

P1b (fill): removed from retarget scope — a fill chain carries editable=true
constraints a label descendant can never satisfy, saving an unreplayable
script. click/press only; replay test proves the recorded fill chain on an
identity-empty editable container still resolves uniquely.

P2a (duplicate container ids): the identity-empty predicate now evaluates
from the DEMOTED identity view — dropped the extractNodeText probe whose
raw-identifier fallback resurrected an id that had been demoted for
non-uniqueness, which made duplicated-container-id rows skip the retarget
they need most. Fixture proves retarget fires; unique-id contrast unchanged.

P2b (guard): replaced the private role-fragment list with the canonical
interactive classification — isSemanticTouchTarget (exported from
core/interaction-targeting.ts, the same policy hittable-ancestor promotion
uses) plus the hittable flag; the module moves to src/core/press-retarget.ts
since selectors -> core would be a layering back-edge. Added the geometric
containment condition: the selected descendant's rect center must lie inside
the container's rect (missing rects fail closed) — the replay tap point must
be provably within the original activation region. Tests: nested Cell (role
the old list missed) blocks; out-of-bounds descendant blocks; rect-less
container blocks.

ADR 0012 decision-3 amendment rewritten to the side-channel design,
click/press-only scope, demoted-view rule, and both guard halves. The daemon
regression runs on the iOS runtime path (direct-iOS is recording-gated) so
the unit lane spends no real wall-clock on Android adb dialog probes.
2026-07-16 19:17:38 +02:00
Michał Pierzchała 991e723e8b fix(android): actionable error when the snapshot helper is unavailable (#1284) (#1285)
* fix(android): actionable error when the snapshot helper is unavailable (#1284)

#1284 kept the hard-fail from #1217 (a silent stock-UIAutomator fallback
produced a materially different, app-window-only capture) but asked for
actionable hints on both failure modes:

- Artifact missing on disk: hint now names the exact `pnpm build:android`
  command, the full dist file set it produces, and notes packaged installs
  ship it via prepack.
- Artifact present but the device rejects the install (adb/OEM policy):
  the underlying adb error already surfaces in the message; the hint now
  explicitly frames this as a device-side failure distinct from a missing
  build artifact, tagged via a new androidSnapshotHelperInstallFailure
  detail set at the adb install call site.

* fix(android): correct helper-missing hint to the two runtime-required files

Review correction on #1285: resolveAndroidSnapshotHelperArtifact only
fs.access'es the versioned .manifest.json and the .apk it references —
the sha256 is a manifest field, and *.idsig is excluded from the npm
package by design. The hint no longer claims the full sidecar set is
required, and the test now rejects any future idsig claim.

* fix(android): cover install rejections and preserve diagnostic identity (#1285 review)

P1: the device-side install marker now covers the whole install phase.
ensureAndroidSnapshotHelper previously tagged only a resolved nonzero
install result; an AndroidAdbProvider.install rejection (enriched
INSTALL_FAILED_* AppError from the provider funnel) bypassed the marker
and fell back to the generic retry/doctor hint. Both paths now flow
through markAndroidSnapshotHelperInstallFailure, which mutates details
in place so the original code, message, hint, details, and cause all
survive. Regression covers the public daemon snapshot route with a real
request handler and an injected provider whose install rejects.

P2: androidSnapshotHelperCaptureError rewrapped through normalizeError,
which lifts diagnosticId/logPath out of details — the rewrap dropped
them (ADR 0010 violation). They are now reinstated into the rewrapped
error's details. Hint selection moved to a helper to keep the function
under the complexity gate.

* fix(android): restore all lifted wire fields through the capture rewrap (#1285 review)

normalizeError hoists hint, diagnosticId, logPath, retriable, and
supportedOn out of details (ADR 0010); the capture rewrap restored only
diagnosticId/logPath, so a transient-classified install rejection (e.g.
connection_dropped) lost its structured retriable signal on the public
daemon error. liftedDiagnosticIdentity is generalized to liftedWireFields
covering the complete hoisted set (hint stays owned by the capture hint
selector). Public route regression: a retriable provider install
rejection keeps error.retriable === true on the daemon snapshot response.
2026-07-16 19:16:19 +02:00
Michał Pierzchała bd62502126 fix(android): bound the scroll-hint dumpsys probe and skip it in wait polling (#1270) (#1288)
* fix(android): bound the scroll-hint dumpsys probe and skip it in wait polling (#1270)

deriveScrollableContentHintsIfNeeded's dumpsys activity top probe ran
with an 8s timeout equal to a whole wait/get budget, and measured
latency ranges from 37ms to ~5.9s under contention. Cap it at 1.5s so
one pathological call can't starve a caller's timeout, and skip hint
derivation entirely during find ... wait polling, since a presence
check never consumes scroll hints.

The wait polling loop actually lives in
commands/interaction/runtime/selector-read.ts's waitForFindMatch
(daemon/handlers/find.ts's own handleFindWait is unreachable for wait
today — dispatchFindReadOnlyViaRuntime always intercepts read-only
find actions first), so the skip-hints option threads through that
capture path down to snapshotAndroid via the existing
flags -> contextFromFlags -> dispatchCommand -> interactor.snapshot
channel.

* fix(android): skip scroll-hint derivation in standalone wait polling too (#1270)

The issue's motivating repro — wait 'label="Battery"' 8000 — polls
waitForSelector, not the find-wait loop, and text/ref waits poll
waitForText (backend.findText -> captureWaitSnapshot on the daemon, or
the snapshotContainsText fallback). All three presence-only polling
captures now disable hidden-content-hint derivation, matching the
Android alert-wait capture which already did. Stable wait keeps full
snapshot semantics.

Adds daemon-route regressions for the exact repro shape on both the
selector-wait and text-wait routes, asserting every per-poll snapshot
dispatch carries snapshotIncludeHiddenContentHints: false.
2026-07-16 18:03:53 +02:00
Michał Pierzchała 1a1ef7c419 feat(android): one persistent automation helper owning snapshot + viewport + canonical injection (#1281)
* feat(android): consolidate touch injection and gesture viewport into the persistent snapshot helper (#1275)

One Android automation helper now owns snapshot capture, gesture viewport
resolution, and canonical one-/two-pointer plan injection. A live persistent
helper session executes gesture/viewport commands over its socket protocol;
without a session the same APK runs one-shot via am instrument. The separate
one-shot multitouch helper APK is deleted (atomic replacement, no fallback).
Touch scheduling/injection is extracted into focused Java classes
(TouchPlan, TouchPlanInjector, PointerEventSchedule, GestureViewportReader)
instead of growing SnapshotInstrumentation. ADR 0013 amended.

* fix(android): stop a structurally-failed helper session before the one-shot viewport retry

A structured ok=false viewport response leaves the session process alive, and
Android permits only one instrumentation owner of UiAutomation - running the
one-shot fallback against a still-live helper contends with it and masks the
original structured failure. Stop the session first; regression pins that the
one-shot retry only executes once the session is gone.

* refactor(android): extract helper touch dispatch into focused classes; split session tests; document helper API v2 (PR #1281 review)

Addresses findings 2 and 3 from PR #1281 review (finding 1, viewport
session-stop ordering, was already fixed in 5961b9247).

- Extract SnapshotInstrumentation.java's one-shot/session touch dispatch
  into TouchCommandHandler.java (viewport/gesture population, UiAutomation-
  parameterized) and SessionResponseWriter.java (session response encoding),
  with shared PROTOCOL/HELPER_API_VERSION/OUTPUT_FORMAT constants moved to
  a tiny HelperProtocol.java. SnapshotInstrumentation.java shrinks from 908
  to 803 lines; wire format (header keys/values, error shapes) is unchanged.
- Split touch-helper.test.ts (~720 lines) into touch-helper.test.ts
  (normalize/parse/one-shot gesture+viewport+result envelope) and
  touch-helper-session.test.ts (persistent-session transport + fake-session
  harness), moving shared device/plan/install-probe fixtures used by both
  files into touch-helper.fixtures.ts.
- Update android/snapshot-helper/README.md to document helper API v2: the
  one-shot viewport/gesture modes, the android-touch-plan-v1 payload shape,
  and the persistent session's socket command/response contract.

* fix(android): invalidate helper session after APK replacement; recycle viewport windows; align ADR 0002 (PR #1281 re-review)

- prepareAndroidTouchHelper now mirrors the snapshot path: when
  ensureAndroidSnapshotHelper replaces the APK (install.installed), the
  persistent session started against the previous binary is stopped before
  any touch command, so gestures run one-shot against the fresh install
  instead of a dead/stale session socket. Regression: 'an APK replacement
  stops the stale session and the gesture runs one-shot' drives a live fake
  session through an outdated-install probe (new outdatedVersionAdb fixture)
  and asserts the session socket receives no gesture, the one-shot
  instrumentation path executes, and the session is gone.
- GestureViewportReader.read no longer leaks AccessibilityWindowInfo: a
  single pass copies the active/focused and first-application bounds into
  locals, every window is recycled in a finally, and the existing precedence
  (active/focused app bounds, root-in-active-window, fallback app bounds,
  IllegalStateException) is applied afterwards, unchanged.
- ADR 0002's touch-synthesis paragraph is amended (2026-07, issue #1275) to
  the shared-helper model, consistent with ADR 0013: a live persistent
  helper session executes touch commands directly, one-shot otherwise; the
  old stop-before-gesture requirement is kept as historical context.

* fix(android): resolve touch helper artifact from the ADB provider like snapshots do (PR #1281 re-review)

- prepareAndroidTouchHelper now uses the same artifact precedence as
  snapshot capture: the scoped adbProvider's snapshotHelperArtifact when
  present, otherwise the bundled resolver (whose strict unavailable error
  is preserved). The provider artifact drives both the install decision
  and the instrumentationRunner used for one-shot commands, so an
  ADB-backed provider that supplies a helper artifact but no native touch
  override runs snapshots and gestures against the same single helper
  (issue #1275). Regression: 'a provider-supplied snapshotHelperArtifact
  overrides the bundled artifact for touch' pins the provider packageName
  on the install probe, the provider apkPath on the install call, the
  provider instrumentationRunner on the am instrument args, and that the
  bundled resolver is never invoked.
- ADR 0002 now states explicitly that one-shot retry applies only to
  idempotent reads (viewport) after the failed session is stopped;
  non-idempotent gesture failures surface directly.
- Helper README session transport corrected: a persistent process serving
  one short-lived socket connection per request (the server closes each
  accepted connection), not a single long-lived connection.

* fix(android): guard touch session reuse on helper identity, stop mismatched sessions (PR #1281 re-review)

Persistent helper sessions are keyed by device, so touch reuse must also
prove the live session runs the helper binary the command selected. The
session record now stores its helper identity (packageName, runner,
helperVersion, helperVersionCode — the same values that feed the snapshot
session identity), and runAndroidSnapshotHelperSessionTouchCommand takes
the requesting helper identity: on mismatch (packageName/runner always;
version/versionCode when both sides define them) it stops the session and
returns undefined, so the touch command runs one-shot against the selected
artifact — gestures never start sessions; the next snapshot restarts one
with the right artifact. Matching identity reuses the session as before.
Snapshot capture identity and behavior are unchanged.

Regression: 'a provider artifact that mismatches the live session helper
stops it and runs one-shot' — a live fake session from the bundled fixture
artifact, then a gesture through an ADB provider supplying an
already-current artifact with a distinct packageName/runner (no install):
the old session socket receives zero gesture commands, the session is
stopped, the one-shot am instrument args end with the provider runner, and
helperTransport is 'instrumentation'.

* fix(android): include artifact sha in helper session identity; evict stale install memo entries (PR #1281 re-review)

Same-version binary replacement changes only the APK sha, so identity
guards keyed on package/runner/version/versionCode could not detect a
crossover between two artifacts that differ only in bytes:

- The artifact sha256 now joins the helper identity end-to-end:
  AndroidSnapshotHelperCaptureOptions gains helperSha256 (snapshot.ts
  passes artifact.manifest.sha256 alongside version/versionCode), the
  session record stores it, createSessionIdentity includes it (making
  snapshot session reuse sha-aware, consistent with the install path's
  existing sha check), and the touch identity guard compares it via the
  same both-defined rule.
- ensureAndroidSnapshotHelper's install memo now evicts every other
  cached decision for the same device+package when it records an
  install/current decision, so installing B invalidates A's stale
  'current' memo and a later command selecting A re-inspects the device
  instead of skipping the sha check.

Regressions: 'a same-version artifact with a different sha stops the live
session and runs one-shot' (touch-helper-session.test.ts — B owns the live
session, a gesture selecting same-version different-sha A sends zero
commands to B's socket, stops it, and completes one-shot) and 'installing
a same-version different-sha helper evicts the stale install memo'
(snapshot-helper.test.ts — A:current cached, B installed, selecting A
re-inspects and reinstalls instead of serving the stale memo). Both
verified to fail without their fix.
2026-07-16 17:04:42 +02:00
Michał Pierzchała b10c8cfb3a fix: warn agents that armed-repair diagnostic reads are recorded too (#1271 stage 1) (#1287)
During an armed `--save-script` record-and-heal repair, read-only diagnostics
an agent runs to locate the corrected target (snapshot -i, get attrs, find,
is) are recorded into the healed script by default, alongside the corrective
press. The wave-3 E3 repair-economics experiment measured 0/4 trials
producing a clean healed script hands-off, and one recorded `get attrs`
caused a second, self-inflicted identity-mismatch divergence on fresh replay.

This is stage 1 of the maintainer's two-stage triage on #1271: safe interim
guidance only, no recording-behavior change. Stage 2 (defaulting read-only
commands out of the repair transaction) stays gated on an ADR-0012 amendment.

- divergence.ts: buildRepairHintGuidance appends a diagnostics --no-record
  clause to every repairHint's text guidance, gated on
  resume.repairSessionHeld === true (decision 6, R7 C1's armed-repair signal)
  so it never renders on a plain, non-repair divergence.
- cli-help.ts: the "Agent-supervised repair (heal-by-doing)" section in
  `help workflow` now says the same thing.
- Unit coverage: divergence.test.ts asserts the clause is present iff
  repairSessionHeld is true, across record-and-heal/state-repair/caution/
  manual.
- SkillGym regression: agent-device-smoke-suite.ts adds
  record-and-heal-diagnostics-no-record, verified 3/3 against claude-haiku
  and codex-mini live runners.
2026-07-16 13:48:08 +02:00
Michał Pierzchała 042f1c3293 chore: remove unreachable read-only find handling from daemon find handler (#1290)
dispatchFindReadOnlyViaRuntime intercepts every read-only find action
(exists/wait/get_text/get_attrs) inside handleFindCommands and always
returns a response for them, so the legacy handleFindWait polling loop,
handleFindExists/GetText/GetAttrs, attachIssuedRefsGeneration, and the
sessionless-find device resolution behind them were unreachable
(verified empirically in #1288 and again here by planting throws in all
four handlers — the full find suite stays green).

With only mutating actions (click/fill/focus/type) able to reach the
snapshot path, requiresRect is constant true; fold it and drop the
now-dead scope threading, including the legacyIosSparse recovery scope
field that was only ever populated with undefined.
2026-07-16 12:48:02 +02:00
devin-ai-integration[bot] 668b64f3da chore: remove deprecated rotate CLI command alias (#1277) (#1283)
* chore: remove deprecated rotate CLI command alias (#1277)

The rotate CLI command alias was renamed to orientation a few versions
ago and is now removed at the next minor, aligned with the gesture-shim
deprecation window.

- Remove the rotate -> orientation entry from src/cli-command-aliases.ts.
- Add an actionable parser error: invoking rotate now fails with
  "rotate was renamed to orientation".
- Update the command-suggestion guard comments and the true-alias list
  in the curated suggestion map test.
- Update CLI parser/help usage tests and src/__tests__/cli-help.test.ts
  to assert the migration error.
- Remove the rotate deprecation note from the commands doc and add a
  breaking migration note to the changelog.

No other command-name aliases are marked deprecated; long-press,
metrics, tap, launch, and relaunch remain supported true aliases.

Fixes #1277

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: disambiguate removed rotate alias error message (#1277)

Update the migration error so users who meant the two-finger gesture are
pointed to `gesture rotate` instead of `orientation`.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: align CHANGELOG with runtime rotate migration message (#1277)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-16 11:44:20 +02:00
devin-ai-integration[bot] 8246362999 chore: baseline-free production-exports cleanup (#1276) (#1282)
* chore: baseline-free production-exports cleanup (#1276)

Classify and burn down the 32 baseline-tolerated unused production exports.

- Live seams: annotate with @internal JSDoc visibility tags (test hooks,
  introspection helpers, public install-source constant) so fallow no longer
  treats them as dead production exports.
- Wrappers: collapse re-export wrappers in commands/index.ts (ref/selector)
  and daemon/lease-context.ts (buildLeaseDiagnosticsContext); update all
  importers to pull directly from the source module.
- Stale baseline entry: remove the non-existent
  resetAndroidMultiTouchHelperInstallCache entry.
- Empty fallow-baselines/production-unused-exports.json so
  check:production-exports now fails loudly on any new dead export.

Fixes #1276

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: address review feedback on production-exports cleanup (#1276)

- CONTRIBUTING.md: document that intentional non-production exports should use
  JSDoc @internal with a short justification, treated as a reviewed baseline entry.
- isPlatform: fix JSDoc tag to "@internal" and remove conflicting "public" wording.
- ARCHIVE_EXTENSIONS: re-export from src/sdk/install-source.ts so the public
  install-source subpath has a real consumer story for the constant.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: make production-exports check truly baseline-free (#1276)

- Drop --baseline from pnpm check:production-exports and remove the
check:production-exports:baseline generation script.
- Delete fallow-baselines/production-unused-exports.json.
- Update CONTRIBUTING.md to describe the baseline-free behavior and remove
references to reviewed baseline entries for production unused exports.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-16 11:44:07 +02:00
Michał Pierzchała 13b3d4fc88 fix: demote non-unique ids from writer identity/selector chain (#1269) (#1272)
* fix(replay): demote non-unique ids from writer identity/selector chain (#1269)

Android list-row GET replays bind the wrong row because the recorder uses
the non-unique framework resource id `android:id/title` (matchCount 11 on
Settings root) as primary identity; positional drift then makes the
identity verifier correctly refuse with `identity-mismatch`.

Demote an id from identity whenever it matches more than one node in the
record-time tree (capture-time uniqueness, not an `android:id/*`
namespace check — a reused RN FlatList testID hits the same class on
iOS). Applied in both places a recorded id feeds identity:

- `computeTargetEvidence` (session-target-evidence.ts): the `target-v1`
  identity tuple falls back to role+label when the id's own capture-time
  match count exceeds one, reusing the existing `filterIdentitySet`
  domain machinery (an empty ancestry degrades it to a plain id scan).
- `buildSelectorChainForNode` (selectors/build.ts): the recorded selector
  chain omits a non-unique id rather than leading with it. Every writer
  call site (get/press/fill recording, plus the divergence-suggestion
  path) now passes the record-time tree so the check has something to
  count against; omitting it preserves prior behavior for isolated-node
  callers (tests).

Resolver-side `resolveSelectorChain` and live press/fill resolution are
untouched per ADR 0012 (disclosed-not-changed disambiguation) — this is
writer/replay-scoped only.

Amends ADR 0012 decision 3: an id may serve as identity (and lead the
selector chain) only when it uniquely denotes the target in the
record-time tree.

Adds fixtures: an Android duplicated-`android:id/title` list (the
measured repro) and an iOS/RN duplicated-testID FlatList shape, both
demoted and still verifying via the now-selective label; a regression
case confirming an already-unique id is unaffected.

Out of scope: the Android list-*press* class (matchCount 12, label-less
`role="linearlayout"` container with no id at all to demote) needs a
separate design decision — deriving identity from the labeled
descendant. Tracked as a follow-up, not attempted here.

* fix(replay): unify the id-uniqueness predicate across both writer sites (#1269 review)

Address the maintainer review on #1272:

1. ONE shared uniqueness predicate. The two demotion sites were counting
   id matches with DIFFERENT semantics — `demoteNonUniqueId` via
   `filterIdentitySet` (NFC + 256-byte cap, and a broken-parent-walk
   exclusion), `selectableId` via a raw `normalizeSelectorText` scan (trim,
   no NFC/cap, no exclusion) — so the identity tuple and the selector chain
   could disagree and half-demote (id gone from one, kept in the other).
   Extract `idMatchCountInTree(nodes, id)` in target-identity-node.ts,
   counting over the canonical `readNodeLocalIdentity` id the replay
   verifier keys on, with no ancestry/parent-walk exclusion. Both
   `demoteNonUniqueId` and `selectableId` now call it. Corrects the
   inaccurate "vacuously-true / plain id scan" comment.

   Cross-invariant test (build.test.ts): for the same node+tree,
   evidence.id === undefined iff the built chain has no id= clause — across
   demoted, unique, and a non-NFC (decomposed vs precomposed) edge case.
   Verified it fails under the old raw-scan and passes under the unified
   predicate.

2. End-to-end reorder proof (session-replay-target-classification.test.ts):
   record against a tree whose rows share android:id/title, then classify
   against a DIFFERENT replay tree where the shared-id rows reorder — the
   demoted role+label identity rebinds the correct row (verified,
   matchCount 1) while `id="android:id/title"` resolves ambiguously (null).
   This pins the FDR 1.0 -> 0 mechanism, not just record-time demotion.

3. Removed the conflated "20/20 clean" live-number comment from the unit
   test; it now states the mechanism (role+label selectivity) instead.

Behavior for the already-clean unique-id path is unchanged: for ordinary
ascii ids the canonical count equals the old raw count. The only outcomes
that change are the edge cases the old split mishandled (non-NFC, broken
parent walk) — where demotion is the correct result. The kept clause still
emits the chain's own normalizeSelectorText id string, so unique ids lead
the chain exactly as before.

* fix(replay): thread record-time tree through the extracted suggestion helper

Rebase-conflict resolution against origin/main. #1217 (typed direct Maestro
engine) extracted `buildReplayDivergenceSuggestionForNode` out of
`resolveSuggestionCandidate` and added a second caller in
`session-replay-maestro-failure.ts`. My #1269 change had added `nodes` to
the `buildSelectorChainForNode` call that #1217 moved into the extracted
helper, so after rebase the helper referenced an out-of-scope `nodes`.

Thread the record-time tree as a required `nodes` param on the helper and
pass it from BOTH callers (each already has it in scope). This keeps the
non-unique-id demotion applied wherever a divergence/repair suggestion
chain is built — now including the typed-Maestro suggestion path — with no
behavior change for the already-unique-id case.
2026-07-16 08:19:56 +02:00
Michał Pierzchała e58cbcdb5f refactor: colocate native platform sources under android/, apple/, linux/ (#1273)
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:

- android-ime-helper/        -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/   -> android/snapshot-helper/
- apple-runner/              -> apple/runner/
- macos-helper/              -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py

Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.

Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
2026-07-15 21:47:38 +02:00
Michał Pierzchała 37895caf99 refactor: replace Maestro compat with typed direct engine (#1217)
* test: add pinned Maestro conformance harness

* feat: add typed Maestro program IR parser

* docs: define direct Maestro engine architecture

* test: compare Maestro oracle with typed IR

* feat: add direct Maestro program engine

* refactor: narrow Maestro execution context

* refactor: tighten Maestro program parsing

* fix: verify iOS Maestro visibility waits

* refactor: isolate retained Maestro runtimes

* refactor: type Maestro target resolution

* refactor: harden typed Maestro execution

* refactor: share in-page swipe planning

* feat: add typed Maestro runtime port

* refactor: parse Maestro suite metadata from typed IR

* refactor: centralize Maestro include loading

* feat: execute Maestro files through typed engine

* refactor: share replay built-in variables

* fix: make Maestro target intent explicit

* fix: refresh Maestro targets before input

* refactor: format Maestro progress from typed IR

* feat: compile typed Maestro replay plans

* feat: bind typed Maestro runtime to public commands

* feat: route Maestro YAML through typed runtime

* refactor: remove legacy Maestro runtime

* refactor: remove obsolete replay control model

* refactor: split typed Maestro plan modules

* fix: harden typed Maestro runtime semantics

* docs: update direct Maestro architecture

* fix: reconcile Maestro runtime with merged contracts

* fix: harden typed Maestro execution boundaries

* fix: harden typed Maestro runtime evidence

* perf: avoid eager Maestro device resolution

* refactor: finalize typed Maestro execution

* fix: reject Android system-only helper snapshots

* fix: preserve Android system dialog snapshots

* fix: make helper-backed CI deterministic

* refactor: invalidate Maestro observations before dispatch

* fix: make Maestro selector policy explicit

* refactor: remove Maestro ranking sentinels

* refactor: make Maestro own observation stabilization

* refactor: source Maestro compatibility presets

* refactor: keep Maestro failure reports typed

* refactor: simplify Maestro runtime policy

* fix: isolate Maestro engine failures

* refactor: consolidate Maestro swipe presets

* fix: align Maestro selector and observation semantics

* fix: preserve atomic iOS Maestro taps

* fix: require semantic uniqueness for Maestro taps

* fix: preserve Maestro parse provenance

* docs: pin Maestro compatibility presets

* docs: reconcile Maestro gesture viewport contract

* perf: resolve Maestro gesture viewport directly

* test: align Maestro replay regressions

* fix: order Android gesture lift after endpoint

* fix: settle Maestro gestures before continuation

* fixup! fix: order Android gesture lift after endpoint

* refactor: normalize Maestro swipes once

* refactor: fail impossible Maestro observations

* refactor: normalize Maestro defaults alias

* test: reconcile Android provider scenarios

* fix(android): synchronize single-pointer move events

* test: align repair digest parsing

* refactor: type Maestro runtime operations

* refactor: keep Maestro controls compact

* refactor: name Maestro diagnostic limit

* fix: align Maestro parser and settling semantics

* fix: complete Maestro compatibility semantics

* docs: define Maestro compatibility boundaries

* fix: refresh iOS runner target after relaunch

* fix: reset prewarmed iOS runner after URL open

* fix: preserve iOS Maestro target and swipe intent

* fix: harden direct Maestro runtime semantics

* fix: preserve ranked Maestro replay suggestions

* fix: align maestro tap runtime semantics

* fix: stabilize maestro ci contracts

* fix: tighten maestro runtime architecture

* fix: reconcile maestro replay with latest main

* perf: tighten Maestro iOS stabilization

* fix: preserve Maestro app lifecycle sessions

* fix: restore Maestro CI coverage

* fix: address Maestro engine review findings

* refactor: consolidate Maestro compatibility internals

* fix: scope Maestro target evidence to childOf
2026-07-15 21:26:42 +02:00
Michał Pierzchała 22a3c4711c refactor(daemon): remove the superseded coarse snapshotRefsStale marker (ADR 0014 step 8) (#1268)
* refactor(daemon): remove the superseded coarse snapshotRefsStale marker (ADR 0014 step 8)

The coarse `snapshotRefsStale` client-stale marker is fully superseded by the
ref-frame model and is removed:

- `setSessionSnapshot` and `buildNextSnapshotSession` no longer set/clear it —
  replacing the latest observation is a read that never touches the frame.
- Read-only ref staleness now derives from frame state: a plain ref warns once
  the frame has EXPIRED (a device side effect changed the screen), and a
  read-only capture no longer marks refs stale because it does not expire the
  frame. Pinned-ref warnings keep comparing against the frozen frame epoch.
- Deletes `markSessionSnapshotRefsIssued` (its only job was clearing the marker)
  and the `session.snapshotRefsStale` field.

Migrates every test off the marker to the frame model (frame-expiry drives the
read warning; complete/partial activation drives admission), and updates the
ADR status + module docs to record step 8 as landed. Ships as follow-up to the
merged #1257 since that PR closed before this step.

Full unit-core + provider-integration green; tsc/lint/fallow/production-exports clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

* fix(daemon): resolve @ref reads from the frame tree; scope find's internal warning

Address three review blockers on the coarse-marker removal (ADR 0014 step 8):

1. @ref reads now bind against the authorized frame tree
   (`refFrameSnapshot ?? snapshot`) in `requireSnapshotSession`, so an
   internal read-only capture that replaced the observation cannot let a
   plain `@eN` resolve a different element by positional coincidence.
   Missing frame evidence fails instead of falling through to a newer
   observation.

2. A mutating find's internal leaf dispatch (`internal.findResolvedTarget`)
   no longer attaches a stale-ref warning in either the press or fill path —
   the caller never consumed a `@ref`, so the public find response must not
   claim it did.

3. `resolveRefStalenessWarning` checks frame expiry FIRST, matching the
   admission order: an expired frame is stale for any ref, even a pin that
   matches the epoch (a matching pin proves identity within the retained
   frame, not that the UI is current).

Regressions: divergent observation-vs-frame trees resolve from the frame
tree or fail when evidence is missing; a locator-based mutating find from an
expired frame carries no stale-ref warning; the reordered resolver unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

* fix: correct stale-ref warning comments and ADR-0014 present-tense marker refs

The get/wait dispatch comments in selector-runtime.ts still described the
superseded coarse snapshotRefsStale marker ("warn when that tree was
replaced since the client last received refs") even though staleness is
now derived from ref-frame expiry (ADR 0014 migration step 8). Reworded
both to describe the frame-derived mechanism actually implemented by
resolveRefStalenessWarning.

session-snapshot.ts's early-return comment in markSessionPartialRefsIssued
referenced "the coarse marker" as something still left untouched, but that
field no longer exists — reworded to name the ref frame fields it actually
preserves.

ADR-0014's "Ref frames are separate from operational observations" section
still described snapshotRefsStale as part of "the existing... implementation"
in present tense, contradicting the Decision section's own note (line 39)
that migration step 8 already removed it. Reworded to keep the historical
mention while stating the removal.

* fix: frame-lifetime wording for the stale-ref warning and read comments

Address the follow-up review blocker plus the co-located terminology cleanup
(ADR 0014 step 8):

- STALE_SNAPSHOT_REFS_WARNING no longer claims "the session snapshot changed";
  it now describes frame lifetime in terms valid for both read warnings and
  mutation rejection — the UI may have changed since the refs were issued, so
  take a new snapshot before relying on or interacting with them. The warning
  fires on frame expiry, including device side effects where no stored snapshot
  changed.
- selector-runtime.ts: the get/wait @ref comments now say the read binds to the
  retained ref-frame evidence and its staleness is frame-derived, not a property
  of the stored snapshot or the live polling capture.
- settle.ts: an unsettled stored capture replaces the observation without
  touching the ref frame; read staleness is driven by side-effect-seam expiry,
  not by storing a fresh observation.
- interaction-settle.test.ts: renamed the settle test off the removed
  stale-marker language to "activates a partial ref frame" (what it asserts).

Comments/test-name/warning-text only — no runtime behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

* fix(daemon): name the ref-frame epoch in the pinned-stale-ref warning

The pinned-ref warning is compared against refFrameEpoch(session) — the frozen
frame epoch — not the latest observation generation, and after a read-only
capture those two diverge. The message still said "the session tree is now sN",
which is ambiguous once the observation counter has advanced past the frame
epoch. Name the ref-frame epoch instead:

  Ref @e12 was minted from snapshot s3 but the session's ref frame is now s15 —
  re-run snapshot -i.

Renames the builder param to `currentFrameEpoch` and corrects its doc comment to
say the pin is compared against the frame epoch, not the stored tree generation.

Regression: `resolveRefStalenessWarning` names the frozen frame epoch, not the
bumped observation generation — a read-only `setSessionSnapshot` advances the
observation counter (15 -> 16) while the frame epoch stays frozen at 15; a pin
at s15 is clean and a pin at s12 names s15, never s16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmnraQhvxzEFEUoUiqRpJa

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 20:55:15 +02:00
Michał Pierzchała cc1a0ec02d fix: clear the accessibility cache before each Android snapshot capture (#1259)
The snapshot helper keeps one UiAutomation connection hot across captures in
a persistent session. Its per-connection accessibility node cache is not
always invalidated by an in-place content swap that keeps the same
window/Activity — e.g. Jetpack Navigation 3, which renders every destination
inside a single AndroidComposeView. After the second such navigation
getWindows()/getRootInActiveWindow() keep serving the previous screen, so
every selector on the new screen fails to match even though the app has moved
on. `uiautomator dump` is unaffected because it uses a fresh connection with
an empty cache each time.

The #861 recovery only rescues empty/system-only/content-poor captures, so a
stale-but-plausible app screen slips past it. Fix the root cause instead:
drop the cache before every traversal. On API 34+ use the documented
UiAutomation.clearCache(). On older platforms, which lack it, re-apply the
current service info via UiAutomation.setServiceInfo() — a public API that
internally calls AccessibilityInteractionClient.clearCache() (verified in
AOSP from API 24 through 33). The internal cache API cannot be reflected
directly: it is a blocklisted non-SDK member, so hidden-API enforcement makes
it unreachable from the helper process.

Fixes #1254
2026-07-15 13:59:23 +02:00