Commit Graph

1113 Commits

Author SHA1 Message Date
Michał Pierzchała fe3e42fcd4 chore: model terminal script publication states 2026-07-20 18:13:38 +02:00
Michał Pierzchała 911dfc2170 chore: align replay prototype on save-script 2026-07-20 16:57:16 +02:00
Michał Pierzchała 8b6162e338 chore: capture active-session replay prototype 2026-07-20 16:24:35 +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
Bills Booth a4588155d7 fix(apple): avoid blocked interactions on physical iOS (#1263)
* fix(apple): handle system alerts on physical iOS

* fix(apple): skip redundant foreground activation

* fix(apple): recover from blocked modal routing probe

* test(apple): cover foreground activation skip

* fix(apple): include modal probe in alert deadline

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-15 13:31:49 +02:00
Michał Pierzchała 95c1e201cb feat: --force/--overwrite for --save-script + arm-time EEXIST preflight (#1266)
* feat: add --force/--overwrite for --save-script, arm-time EEXIST preflight

#1235 made healed-script publication refuse-on-exist. #1258 adds an escape
hatch: --force (alias --overwrite) on open/close/replay makes
publishHealedScriptAtomically atomically REPLACE an existing target
(renameSync) instead of refusing. The flag threads CLI -> daemon request ->
SessionState.saveScriptForce (persisted at arm time, like saveScriptPath, so
a later close/auto-commit that doesn't repeat the flag still honors it) ->
the publish primitive. Default (flag absent) is unchanged: refuse-on-exist.

Second half: an arm-time EEXIST preflight in session-replay-runtime.ts now
fails a repair-armed replay --save-script BEFORE any step dispatches when
its target already exists and --force is not set, instead of only failing
at publish time after the whole repair run (and its corrective steps) has
already executed against the device.

* refactor: extract write() catch-block into handleSessionScriptWriteFailure

Pure structural refactor, no behavior change: moves the diagnose +
classify-and-return / AppError-rethrow logic out of SessionScriptWriter.write
into a module-level helper. Drops write()'s cyclomatic below Fallow's
threshold (the finding CI flagged on #1266) — the extracted throw still
propagates out of the catch exactly as before.

* fix: make persisted save-script force consistent (preflight + per-target)

Addresses two authorization inconsistencies in the #1258 --force work
flagged in re-review:

1. Arm-time EEXIST preflight now uses the SAME effective force decision as
   publication — `req.flags?.force || preRunSession.saveScriptForce` — instead
   of the live flag alone. A repair armed with `--save-script --force` and
   continued via `replay --from … --save-script` (without repeating --force)
   is no longer rejected on a target the earlier forced leg authorized.

2. Force is now per-target, not sticky-across-retarget. Re-arming a DIFFERENT
   `--save-script=<other>` without a live --force CLEARS the persisted
   `saveScriptForce` (new shared `applySaveScriptRetarget`, used by both the
   replay armer and `recordActionEntry`/close), so a retarget can never
   silently overwrite a file nobody opted into. A live --force on the retarget
   re-grants it for the new target. Updated the SessionState doc accordingly.

Regressions: persisted-force `--from` continuation is not preflight-rejected;
retarget-without-force refuses an existing target (and the --force contrast
overwrites it). Both verified to fail without their respective fix.

* fix: match arm-time preflight force to per-target retarget contract

Re-review ordering-mismatch fix. The preflight computed effective force as
`req.flags?.force || preRunSession.saveScriptForce`, accepting persisted force
regardless of whether THIS request retargets. So a `--from` continuation with
explicit `--save-script=b.ad` (no live force) on a session forced for a.ad
would PASS the preflight, run every step, then have `applySaveScriptRetarget`
clear the force for b.ad, and finally refuse the existing b.ad at publish time
— defeating the arm-time-preflight point and mutating the session mid-run.

Now the effective-force decision is computed inside `preflightSaveScriptTarget`
against the target THIS request resolves to (the same one the armer will set),
matching `applySaveScriptRetarget`'s per-target contract: live force always
bypasses; persisted force bypasses ONLY when `targetPath === existingSaveScriptPath`
(same target). A retarget to a different path without live force is now refused
BEFORE any step dispatches. The preflight stays read-only (runs before the
armer; never mutates the session).

Regression: a --from continuation retargeting to an existing b.ad without live
force fails at the preflight, dispatches zero steps, and leaves saveScriptPath/
saveScriptForce unchanged. Verified it fails (leg passes preflight, runs) when
the fix is reverted. Same-target continuation and live-force retarget stay
correct.

* fix: expose close savedScript to clients; preserve COMPLETE on retarget reject

Two re-review blockers.

BLOCKER 1 (client-contract gap): the typed close APIs accept --save-script/
--force inputs but dropped the daemon's `savedScript` response, so a Node
client could request publication but not learn where the file landed. Add
`savedScript?: string` to SessionCloseResult and AppCloseResult, and project it
via readOptionalString in both close normalizers (sessions.close, apps.close).
New public-client coverage asserts it round-trips (and is absent when the
daemon published nothing).

BLOCKER 2 (P1 ordering): the C2 `saveScriptComplete = false` reset ran BEFORE
the EEXIST preflight's early-return, so a retarget REJECTION corrupted a prior
COMPLETE transaction — a later close would then refuse to commit the original
target. Move the reset to AFTER `if (saveScriptPreflight) return ...` (verified
nothing between the two positions reads saveScriptComplete; applySaveScriptRetarget
already runs later, so the original saveScriptPath is preserved). The reset is
correct only when the run proceeds to re-arm.

Strengthened the retarget-rejection regression to assert (a) saveScriptComplete
survives the rejection and (b) a later bare close commits the ORIGINAL target,
not the rejected new one. Both verified to fail without their respective fix.
2026-07-15 10:16:42 +02:00
Michał Pierzchała 6efe54451b fix: unify divergence screen capture with snapshot's full-window scope (#1265)
* fix: unify divergence screen capture with snapshot's full-window scope

Route captureDivergenceObservation through captureSnapshotData — the same
function the snapshot command itself builds its capture with (Android's
snapshot-helper full-window route with its graceful app-scoped fallback,
iOS's bounded system-modal probe path, macOS/Linux surface-scoped branches)
— instead of a parallel hand-rolled dispatchCommand call. The chrome filter
and meaningful-target filter stay layered on top as filters over that full
capture, never as a scoping.

Amends ADR-0012 decision 4 to state the invariant: an agent must never see a
healthier `screen` in a divergence report than a plain `snapshot` would show
it, so a separate-window system overlay (volume dialog, quick-settings
shade, permission dialog) must survive into `screen.refs` exactly as
`snapshot` would present it.

Also fixes the synthetic `volume_dialog_slider` id in
snapshot-chrome-android-statusbar.test.ts to the real, live-verified
`volume_new_ringer_active_icon_container` id and rewords the test comment to
read as a filter-logic unit test rather than a live-capture-path claim, and
adds unit coverage for the invariant itself.

Fixes #1264

* fix: rank divergence screen.refs within the cap so overlays are not buried

The #1264 root cause is cap burial, not capture scope: buildReplayDivergenceScreenRefs
sliced candidates in document order, so a fully-captured separate-window
overlay (volume dialog, QS shade, permission dialog) that enumerates after
the app window's ~77 nodes lands past position 20 and is truncated away —
the report shows a healthy-looking app under a covering overlay it cannot see
(archived evidence: screen.truncated: true, zero volume refs).

- Rank within the cap instead of document-order slicing: foreign-window
  (non-app-bundleId) hittable nodes — the dismiss targets for whatever covers
  the app — are promoted ahead of app content, otherwise stable (document
  order preserved within each tier; equal-priority app nodes never reshuffled).
  The 20-cap is a byte bound, not a first-20-in-tree-order policy.
- Occlusion fallback: when a system overlay mass-covers the app (every app
  node annotated interactionBlocked: 'covered'), surface those covered nodes
  rather than emitting an empty screen.refs — a report whose capture holds
  meaningful nodes but whose refs is empty is broken by construction.
- repairHint/suggestions consume the full captured node list, not the capped
  refs slice, so hint routing is unaffected; only screen.refs selection changes.

Detection keys off node.bundleId (Android-only, from the a11y package); iOS/macOS
leave per-node bundleId undefined, so ranking degrades to document order there
(safe — those platforms surface modals via the probe path, not by cap-competing).
Guarded on a known appBundleId so a sessionless capture never reorders.

Tests: replaces the small-fixture #1264 test (which the overlay fit inside the
cap regardless of order, so it did not prove the invariant) with a realistic
full fixture (24 app controls + overlay dismiss-target captured LAST) that
fails on document-order slicing and passes with ranking; plus occlusion tests
(mass-covered app -> overlay surfaces, refs non-empty; bare-scrim fallback ->
covered app nodes surfaced, refs non-empty). Both were verified to fail before
the fix. ADR-0012 decision 4 amendment reworded to cover ref-selection ranking
and the occlusion guarantee, not only capture scope.

Refs #1264

* fix: route divergence capture through captureSnapshot wrapper + clean flags policy

Completes the #1264 capture unification. The prior round routed
captureDivergenceObservation through captureSnapshotData (the inner single-shot
capture), but plain `snapshot`'s backend calls the HIGHER captureSnapshot
wrapper, which owns Android freshness + post-action retry. A divergence could
therefore consume the first stale/app-scoped dump while a plain `snapshot`
retries to the fresh full-window tree — a divergence staler/narrower than
`snapshot`, violating the invariant.

- Route the divergence capture through the same `captureSnapshot` wrapper as
  plain snapshot, so it inherits freshness/post-action retry parity. No fork:
  the wrapper's params (device, session, flags, logPath) are all suppliable
  from the divergence path.
- Build the divergence capture's flags from a clean, fixed policy
  (`divergenceCaptureFlags`: full-window, non-raw, default depth) instead of
  spreading the failed action's flags — so a failed `snapshot --raw`/scoped/`-d`
  action can no longer narrow the diagnostic tree. Only the interactive-only
  policy is carried (extracted as a helper so captureDivergenceObservation
  stays within complexity budget).

Tests: a freshness-retry regression (session carries an active Android
freshness marker; capture-1 is a stale near-empty dump that trips sharp-drop,
capture-2 holds the overlay — asserts the divergence uses the retried fresh
tree and dispatched twice), and a clean-flags regression (a failed
raw/scoped/depth action — asserts the snapshot dispatch context drops
snapshotRaw/scope/depth while still applying interactive-only). Both verified
to fail on the pre-fix code. ADR-0012 decision 4 amendment updated to state the
same-wrapper (freshness parity) and clean-flags guarantees.

Live overlay acceptance remains a maintainer device step (env down): unit
fixtures prove ref SELECTION after nodes are supplied, not that the Android
helper returns the separate-window overlay at divergence time.

Refs #1264

* test: stub the freshness-retry sleep so the capture-parity test doesn't real-wait

The #1264 capture-parity regression exercised the real Android sharp-drop
retry, which awaited the real ~250 ms `sleep` delay — repo guidance forbids
real-time waits in unit tests. Mock `sleep` (the delay the retry path in
snapshot-capture.ts awaits) to a no-op at the module level, so the retry
BRANCH still executes (loop runs, retries, re-captures) without a wall-clock
wait. The test still proves the branch: two on-device dispatches and use of
the retried fresh tree (overlay present). Verified it still fails on the
pre-fix single-shot path (1 dispatch) with the delay stubbed, so the stub does
not make it vacuous. No production change; the delay stub needs no DI seam
since `sleep` is a plain module export.

Refs #1264

* fix: reconcile divergence ref selection with #1257 ADR-0014 partial ref frame

Rebase reconciliation. #1257 (ADR-0014 session ref-frame lifetime) landed on
main and changed captureDivergenceObservation to activate a PARTIAL ref frame
(markSessionPartialRefsIssued) authorizing exactly the divergence screen's
emitted refs — computing that "digestBodies" set with its own document-order,
non-covered-only filter. My #1264 change made buildReplayDivergenceScreenRefs
emit a DIFFERENT set (ranked, occlusion-fallback, meaningful-filtered), so the
authorized frame would no longer match the shown screen: in the mass-covered
fallback the screen surfaces covered refs that #1257's non-covered-only frame
filter excluded, leaving the agent a ref the screen advertised but the frame
rejects.

Extract selectDivergenceScreenRefNodes as the single source of truth for which
nodes screen.refs publishes and in what order. Both the rendered digest
(buildReplayDivergenceScreenRefs) and the partial-frame authorization
(captureDivergenceObservation -> markSessionPartialRefsIssued) derive from it,
so the frame authorizes exactly the emitted set — preserving BOTH #1257's
ADR-0014 intent and #1264's ranking/occlusion intent. Also refresh the
captureDivergenceObservation doc to the partial-frame sequence.

Test: assert the partial ref frame scope (session.refFrameScope) equals the
emitted screen.refs set in the mass-covered fallback (covered refs included) —
verified to fail on #1257's original non-covered-only digestBodies.

Refs #1264 #1257
2026-07-14 21:46:56 +02:00
Michał Pierzchała 54977f3b87 feat(daemon): ADR 0014 session ref-frame lifetime — full implementation (#1257)
* feat(daemon): classify ref-frame effect on every daemon command (ADR 0014 step 2)

Add the ADR 0014 `refFrameEffect` trait to the daemon command descriptor
facet: every command that reaches a session-owning daemon leaf declares how
it relates to the session's authorized ref frame — `preserve`,
`may-invalidate`, `delegated`, or a request-sensitive resolver for
subaction-dependent commands (keyboard status vs dismiss, alert get/wait vs
accept/dismiss).

This is the honesty/completeness guard, not the transition site: a
`may-invalidate` command still calls the (future) ref-frame module only when
its mutating path runs. No runtime behavior changes here.

- `RefFrameEffect` / `DaemonRefFrameEffect` types and a `resolveRefFrameEffect`
  accessor honoring the resolver form, mirroring the existing closure traits.
- Classify all 58 daemon-faceted commands; `find` is the honest superset
  (`may-invalidate`) pending a read/mutate resolver during enforcement wiring.
- Give `app-switcher` a daemon facet (route unchanged) so the generic-fallback
  escape hatch the ADR calls out is covered instead of silently unclassified;
  drop it from parity's UNROUTED set.
- Completeness gate (`ref-frame-effect.test.ts`): every daemon-projected
  command classifies an effect, every public command is classified or in the
  explicit non-daemon allowlist (`install-from-source`, which projects via the
  `install_source` internal command), and the resolvers/app-switcher resolve as
  declared.

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

* feat(daemon): introduce ref-frame module + admission matrix (ADR 0014 step 1)

Introduce `src/daemon/ref-frame.ts` as the single owner of the ADR 0014
ref-frame model — the authorization namespace for mutation refs, kept distinct
from the latest operational observation (`session.snapshot`). It defines the
frame's issuance scope and lifecycle state and the pure mutation-admission
matrix (`admitRefMutation`) with the ADR's typed, order-sensitive reasons:
ref_frame_expired, ref_generation_mismatch, plain_ref_requires_complete_frame,
ref_not_issued.

The frame is introduced behind the existing `snapshotGeneration` (epoch) and
`snapshotRefsStale` (coarse client-stale) fields, whose wire-visible names
(`refsGeneration`, the `@e12~s42` pin grammar) are unchanged. New
`refFrameState`/`refFrameScope` session fields default to active/all, so the
matrix currently reduces to the generation-pin check the iOS path already did —
no behavior change. Expiration at the side-effect seam and non-`all` scope land
in later steps.

The existing #1241 iOS stale-ref guard now routes its decision through
`admitRefMutation` (plus the transitional coarse-stale check for plain refs),
so the module is production-live; the external error contract is identical.
Adds a unit test covering the full admission matrix and reason ordering.

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

* feat(daemon): wire pre-side-effect frame expiration at the seams (ADR 0014 step 3)

Route device mutations through the idempotent ref-frame transition. A leaf
expires the current frame synchronously, immediately before awaiting the device
operation, so success, timeout, cancellation, or connection loss all leave it
expired — there is no success-only rollback.

Seams wired:
- interaction runtime backend closures (tap/click, fill, longPress, native web
  clickRef/fillRef, gesture, type) — post-resolution, pre-dispatch, so a
  resolution failure before the seam preserves the frame;
- the generic daemon leaf (back/home/rotate/scroll/tv-remote/app-switcher/
  viewport/focus, ...), gated by the daemon `refFrameEffect` classification via
  `resolveRefFrameEffect`, which is that resolver's first production consumer.

Re-authorization: issuing a complete namespace re-activates the frame —
`markSessionSnapshotRefsIssued` and the snapshot command's
`buildNextSnapshotSession` — so a fresh capture between mutations restores
usability. A diff or kept tree preserves the prior authorization state; internal
read captures never re-authorize.

Enforcement of the new expired-frame rejection is intentionally deferred to step
7, which the ADR gates on fresh live device evidence per platform. The iOS
#1239 guard therefore stays armed-but-not-enforced here: it consults the
admission matrix but still rejects only on the pre-existing conditions (pinned
generation mismatch, coarse plain-ref stale marker), so behavior is unchanged.
Tests prove the transition is wired (a press expires the frame; a re-issue
re-activates it) alongside the idempotency and re-authorization unit tests.

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

* fix(daemon): address ADR 0014 review — partial issuance, keyboard, seam coverage

Exact-head review found three blockers; all fixed with focused seam tests.

1. Partial issuance no longer restores complete authority. Every caller of
   `markSessionSnapshotRefsIssued` (find, settled diff, replay divergence) is a
   PARTIAL publication, but it re-activated a complete `all`-scope frame. It now
   only clears the coarse marker; complete re-authorization is reserved for the
   snapshot command (`activateCompleteRefFrame`, from `buildNextSnapshotSession`).

2. Keyboard resolver covers every mutating subaction. keyboard accepts
   status/get/dismiss/enter/return; only status/get read, so dismiss/enter/return
   (enter/return dispatch a real return key) are now `may-invalidate`. Alert reads
   are likewise a named set. Completeness test extended.

3. Remaining step-3 leaf seams wired: the direct iOS selector fused dispatch, the
   direct `find` focus/type dispatches (find click/fill already delegate through
   the interaction leaf), and Android blocking-dialog recovery (expire before the
   recovery tap). Focused seam tests for each prove the frame expires.

Enforcement of the expired-frame rejection remains deferred to step 7 behind the
ADR's per-platform live-evidence gate; behavior is unchanged.

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

* feat(daemon): cross the seam at every specialized mutating leaf (ADR 0014 step 3 complete)

Wire expireRefFrame at the remaining may-invalidate leaves so EVERY mutating
daemon leaf crosses the side-effect transition, not just the interaction/generic
paths:

- keyboard dismiss/enter/return, push, trigger-app-event (shared session leaf) —
  gated by resolveRefFrameEffect so keyboard status/get preserve the frame;
- alert accept/dismiss (get/wait preserve, via the alert resolver);
- settings mutations;
- React Native overlay dismissal;
- install / reinstall (deploy op);
- open / relaunch — expires the reused session's frame before the launch;
- close — expires for uniformity, though a successful close deletes the whole
  session (and its frame) anyway.

Seam tests: keyboard dismiss expires while status preserves (proves the
resolver-gated pattern), and RN overlay dismissal expires. Enforcement stays
deferred; behavior unchanged.

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

* feat(daemon): partial issuance scope + MCP pin retention + pinned CLI refs (ADR 0014 step 4)

A find/settled-diff/divergence result publishes only the refs it returned, so it
now activates a bounded PARTIAL frame authorizing exactly those ref bodies
(`markSessionPartialRefsIssued`) instead of nothing — a plain ref then requires a
complete frame and a pinned ref outside the set is rejected. An empty partial
result leaves prior authority intact.

- read-only find publishes its one ref; settled diff publishes its added lines +
  `refs` + `tail`; divergence publishes its capped, non-covered, non-chrome
  digest set.
- MCP: a mutating `find` returns no `refsGeneration` and is explicitly
  non-issuing — it no longer hits the missing-generation branch that wiped the
  whole per-session pin scope (forwarding the old pin is how the daemon produces
  a precise stale rejection).
- Human-CLI partial results render reusable refs in ready-to-copy `@eN~s<gen>`
  form (find + settled tail); JSON/Node keep plain bodies + one response-level
  generation, and MCP stays plain (it auto-pins). Output-economy waiver covers
  the +8-byte tail-pin increase with an ADR justification; the workflow oracle
  treats a pinned ref as surfacing its plain body.

Enforcement of the frame's expiry and partial-scope rejections stays deferred to
step 7 (behind the ADR's per-platform live-evidence gate), so this is
behavior-preserving; the iOS guard now consumes the admission verdict for a
typed `details.reason` on the rejections it already emitted.

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

* feat(daemon): resolve refs against the authorized frame tree (ADR 0014 step 5)

Retain the ref frame's immutable source tree (shared reference, no deep
copy) and resolve a `@ref` against it rather than the latest operational
observation. An Android freshness — or any read-only — capture advances
`session.snapshot` without disturbing the frame tree, so the two
intentionally diverge.

At resolution, adopt the fresh observation's node (its current on-screen
coordinates) ONLY when its local identity still matches the authorized
node — the legitimate "element moved" case. If a different element now
sits at that index, keep the authorized frame node so a positional
coincidence cannot retarget the action.

Expose the frame tree to the command runtime through
`CommandSessionRecord.refFrameSnapshot`; pre-frame sessions fall back to
`snapshot` and behave exactly as before.

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

* feat(daemon): fail-closed ref-mutation enforcement across platforms (ADR 0014 step 7)

Enforce the ref-frame admission matrix on every platform before dispatch:
an expired frame, a superseded generation pin, a plain ref against a
partial frame, or an unissued pinned ref is now rejected with a typed
`details.reason` and an honest message that names the lifetime failure
instead of claiming the ref was missing or lacked bounds. The prior
iOS-only, coarse-marker guard is replaced.

Freeze the frame epoch at issuance (`refFrameGeneration`) so a later
read-only capture that advances the observation counter cannot falsely
reject a correct pin from the issuing frame; staleness warnings compare
against the same frame epoch.

A mutating `find` re-resolves its target by locator against a fresh
capture, so its internal leaf dispatch carries `internal.findResolvedTarget`
and skips ref admission (it still crosses the seam and expires the frame).

Update unit and provider-integration scenarios to the new contract:
multi-mutation ref sequences re-observe between mutations, settled refs are
consumed in pinned form, and rejections assert the typed reason.

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

* docs(adr-0014): promote ref-frame vocabulary and mark implementation status

Flip ADR 0014 to Accepted, promote the ref-frame / frame-expiry-seam /
mutation-admission vocabulary into CONTEXT.md, correct the `@ref`
resolution note to the frame-tree model, record the migration status
(steps 1–7 landed; coarse-marker removal follows live-evidence
confirmation), update ADR 0012's divergence-ref amendment to accepted,
and add a CHANGELOG entry for the fail-closed ref lifetime.

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

* test(daemon): lock ADR 0014 evidence #1 and refresh module docs

Add a daemon-level sequence test proving the canonical contract: after an
unobserved first ref mutation, a second mutation rejects both bare and
pinned with ref_frame_expired, and a fresh snapshot re-authorizes. Refresh
the ref-frame module header and seam-expiry test comment now that
enforcement is live.

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

* fix(daemon): address ADR 0014 exact-head review — six lifetime blockers

1. Android dialog recovery aborts an outstanding ref action: a ref
   press/fill admitted against the pre-recovery frame now fails with
   ref_frame_expired when before-command recovery mutates the UI, instead
   of continuing against the recovered screen (selector/coordinate actions
   still re-resolve and continue).
2. open --relaunch expires the existing session's frame BEFORE the close
   dispatch, so a close timeout/failure that already tore the app down
   still leaves the old frame expired.
3. expireRefFrame clears scoped-snapshot lineage (snapshotScopeSource) at
   the seam, so snapshot -s @ref -> mutation -> snapshot -s @same-ref can
   no longer borrow stale lineage across a device side effect.
4. Missing authorized-frame evidence fails closed: resolveSnapshotForRef no
   longer recaptures and accepts the same ref body from a newer tree by
   positional coincidence. A mutating find's internal dispatch resolves
   against its own fresh capture (omitRefFrameSnapshot), not the frame.
5. Mutating find omits refsGeneration — its acted ref is diagnostic
   pre-action identity and must not be pinnable after the action.
6. An empty partial publication leaves all session state untouched
   (including the coarse marker), instead of clearing it before finding
   there were no refs to issue.

Adds focused regressions (lineage-cleared sequence, empty-partial no-op,
fail-closed on unusable bounds, in-frame label recovery, mutating-find
non-issuance) and extracts the find action dispatch to keep complexity in
budget.

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

* fix: preserve snapshot refsGeneration + shared recovery rejection (ADR 0014 re-review)

P1: structured JSON/Node snapshot results now retain the response-level
refsGeneration. It was declared on the daemon response but dropped by the
public CaptureSnapshotResult type, the serializer, and the Node normalizer,
so default `snapshot -i --json` emitted refs with no generation to pin
against. Added to the type, serializer, normalizer, plus CLI/Node tests.

P2: Android dialog-recovery abort now reuses the SHARED admission rejection
(refMutationAdmissionResponse) instead of a bespoke error, so the failure
carries the full typed context (reason, ref, currentGeneration, scope,
mintedGeneration) identical to every other expired-frame rejection across
platforms. Removes the now-unused AppError/refFrameState imports. Adds a
regression proving recovery aborts the outstanding ref action before any
press dispatch.

Also adds the relaunch failure-boundary regression (existing-session close
fails after dispatch → old frame stays expired), and corrects the ADR
implementation-status note so Android blocking-dialog recovery and a real
provider-backed interaction/lifecycle are recorded as unexercised release
blockers rather than confirmed enablement.

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

* docs(adr-0014): record provider seam as live-verified; Android recovery sole blocker

The provider-backed interaction + lifecycle seam is now confirmed by fresh
live evidence (AWS Device Farm, webdriver backend). Update the ADR
implementation-status note so only Android blocking-dialog recovery remains
an unexercised release blocker — and note it is blocked on a bootable free
Android target plus a deterministic app-owned ANR trigger, not on any code gap.

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

* docs(adr-0014): record Android ANR recovery as an accepted evidence gap

Per the review decision: the Android blocking-dialog recovery seam has no
deterministic app-owned ANR repro in the harness, so it was not live-
exercised. The team accepted shipping without a live run for it — its
transition/abort logic is covered by fixture regressions and it is enforced
in code identically to the verified paths. Reword the status note from an
open release blocker to a documented, accepted evidence gap, which unblocks
step 8's coarse-marker removal.

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-14 21:25:11 +02:00
Michał Pierzchała 0436793c25 fix(replay): extend resume.from record-and-heal shape to caution/manual (#1267)
* fix(replay): extend resume.from record-and-heal shape to caution/manual (#1262)

caution/manual divergences kept resume.from unshifted (correct — per
resolution item 1, N stays unconditionally legal) but never offered a
concrete N+1 continuation for their record-and-heal-shaped alternate
repair, and a last-step caution/manual divergence repaired by a
recorded action was a dead end: pendingRecordAndHeal was only ever
stamped for record-and-heal, so the N+1 empty-tail resume was
unauthorized (out of range) and close on the not-yet-COMPLETE
transaction discarded the just-recorded corrective action — the same
trap #1260 closed only for record-and-heal.

- buildRepairHintGuidance (src/replay/divergence.ts) now renders BOTH
  concrete commands for caution/manual when resume.allowed: --from N
  for a --no-record state fix, --from N+1 for a recorded corrective
  action.
- stampPendingRecordAndHealWatermark (session-replay-resume.ts) now
  also stamps for caution/manual, but only when the diverged step is
  the plan's LAST one and N+1 is independently preflight-safe — a
  mid-plan --from N+1 was already unconditionally legal and un-gated
  for these hints (unlike record-and-heal, they never mandate a
  corrective action), so that pre-existing pattern stays un-gated.

* fix(replay): add resume.alternateFrom so caution/manual dual-path never advertises a --from the daemon refuses (#1262)

The dual-path text guidance offered `--from N + 1` whenever resuming AT `N`
was allowed, but `--from N + 1` needs its OWN preflight — which additionally
requires the diverged step `N` to be skip-safe. When `N` is a runScript
(outputEnv producer) or inside runtime control flow, preflight(N) passes
while preflight(N+1) fails, so the text advertised a command the daemon then
refused (the text-vs-structured disagreement #1260 blocker 2 banned).

- Add optional `resume.alternateFrom` to the decision-4 wire shape
  (`ReplayDivergenceResume`). The daemon populates it (`N + 1`) for
  caution/manual ONLY when `evaluateReplayResumePreflight({ from: N + 1 })`
  passes — the same acceptance condition on both the mid-plan (un-gated,
  in range) and last-step (watermark-stamp) paths. Its checked range is a
  strict superset of `from`'s, so alternateFrom present implies allowed.
- The text renderer gates the `N + 1` command on `alternateFrom`'s PRESENCE
  and renders its value verbatim, never re-deriving resumability — so text
  and the structured wire can never disagree on the advertised next command.
- This also closes a parity gap: a JSON/MCP caller now gets both ordinals
  (previously the dual-path was text-only, structured callers saw only `from`).
- ADR-0012 decision-4 documents alternateFrom as additive/optional; projection
  and trigger tests cover both positions (runScript/control-flow → no
  alternateFrom, no `--from N + 1`; skip-safe → alternateFrom present).

* fix(replay): withhold empty-tail alternateFrom when no session can stamp the watermark (#1262)

The empty-tail alternate (`alternateFrom > actions.length`) is accepted by
the range check ONLY when it matches a stamped `pendingRecordAndHeal`
watermark, and that watermark can only be stamped on a live session. For a
last-step caution/manual failure with no active session — a one-step `open`
failure, or a session closed mid-replay — the watermark can never be stamped,
so the advertised `--from length+1` is then rejected as out of range,
re-introducing the text/structured mismatch this arc fixed.

- Thread `sessionExists` into `buildReplayDivergenceResume`; gate the
  one-past-the-end `alternateFrom` on it (mid-plan alternate stays in-range and
  session-independent). Both divergence sites pass `session !== undefined`.
- At the last step this makes `computeReplayResumeAlternateFrom`'s emit
  condition exactly `computeRecordAndHealWatermark`'s stamp condition, so
  alternateFrom present ⟺ the watermark gets stamped in the same request.
- ADR-0012 decision 4: reword the mechanical resume.from parity statement for
  the dual-path hints (from=N + optional alternateFrom=N+1); update the
  empty-tail paragraph to cover caution/manual last-step stamping and the
  no-session withholding.
- Tests: unit (last-step no-session → no alternateFrom; mid-plan no-session →
  still present) + integration (single-step failure, no session → no
  alternateFrom on the wire, no --from length+1 in text).
2026-07-14 21:15:46 +02:00
Kenichi Saito 236016ed8a fix(ios): support remote-hosted alerts on physical devices (#1232)
* fix(ios): probe remote-hosted system modals (AccessorySetupKit picker) when the springboard mirror yields no hittable actions

* fix(ios): fail closed on host state, guard dismissal re-query, unit-test probe routing

Addresses review on #1232:
- Gate the remote-host probe to a foreground host
  (RemoteHostedSystemModalPolicy.isEligibleHostState); background/unknown hosts
  fail closed instead of substituting an unrelated action tree.
- Wrap the alert-resolution fallback query in safeElementsQuery so a dismissed
  remote host raising kAXErrorServerNotFound is absorbed.
- Extract routing/gating into RemoteHostedSystemModalPolicy and add
  simulator-free unit tests under AGENT_DEVICE_RUNNER_UNIT_TESTS.

* refactor(ios): centralize blocking system modal resolution

* fix(ios): bound alert dismissal rechecks

* feat(ios): enable alerts on physical devices

* fix(ios): bound alert system modal resolution

* test(ios): add AccessorySetupKit picker fixture

* fix(ios): validate remote-hosted system modal interactions

* chore: keep pnpm checks non-interactive

* fix(ios): share alert command deadline

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-14 17:19:17 +02:00
Michał Pierzchała 392dc1cded refactor: rename rotate command to orientation (rotate kept as deprecated alias) (#1252)
* refactor: rename rotate command to orientation, keep rotate as a deprecated alias

The top-level `rotate` command (device orientation: portrait/landscape) shared
a name with the `gesture rotate` two-finger rotation gesture. Rename the
orientation command to `orientation` and keep `rotate` working as a minimal,
silent CLI alias (same mechanism as `tap`->`press`) for a few versions.

The rename is applied across every layer:
- command-descriptor registry `name`, daemon dispatch handler, and the typed
  system facet (metadata/cliReader/daemonWriter/schema/output formatter)
- navigation projection + `CommandResultMap` (`OrientationCommandResult`,
  `action: 'orientation'`), client types (`OrientationCommandOptions`), and the
  runtime family (`device.system.orientation`)
- interactor + backend methods -> `setOrientation` (matching the backend's
  `setKeyboard`/`setClipboard` verb convention); Android helper
  `rotateAndroid` -> `setAndroidOrientation`
- Apple/cloud-webdriver capability keys and plugin gate
- user-facing docs (commands.md, client-api.md)

Client SDK method is `orientation` (client convention = camelCase of the
command name, matching `back`/`home`/`appSwitcher`); execution layers use the
imperative `setOrientation`.

Deliberately unchanged:
- the Swift runner wire protocol keeps `command: 'rotate'` — the runner has its
  own command namespace with no gesture collision, so renaming it would only
  risk CLI<->installed-runner version skew on physical devices
- the `DeviceRotation` value type / `parseDeviceRotation` (names the orientation
  values, no collision)

Note: `client.command.rotate` / `device.system.rotate` and the `RotateCommand*`
exported types are removed (the alias only rewrites CLI tokens); SDK consumers
must use `orientation`. The JSON `action` value changes `rotate` -> `orientation`.

* style: wrap long lines to satisfy oxfmt (orientation rename tests)

* fix: add compatibility layer for the rotate->orientation rename

Addresses review blockers on the CLI-only alias: `rotate` previously
resolved only in CLI token parsing, so command-data/RPC paths that carry
the wire command directly failed descriptor validation, and the removed
typed SDK surface broke shipped consumers.

Central command-alias boundary (was CLI-only):
- Promote `cli-command-aliases.ts` to `command-aliases.ts` as the single
  alias source, applied at each command-name ingress that bypasses the CLI
  parser: the daemon request boundary (`handleRequest`, covering replay and
  older remote clients) and the batch step readers (CLI `batch-steps.ts` and
  daemon `batch-policy.ts`). No hand-synced command tables.

Retain deprecated typed SDK surface (shipped v0.18/v0.19):
- `RotateCommandOptions` / `RotateCommandResult` type aliases (legacy
  `action: 'rotate'` contract) and `SystemRotate*` runtime types.
- `client.command.rotate` and `device.system.rotate` deprecated wrappers
  that delegate to `orientation` and restore the legacy response
  (`action: 'rotate'` / `kind: 'systemRotated'`).

ADR 0014: rename `rotate` -> `orientation` in the invalidation guidance
(lines 229, 237) so the accepted architecture doc matches the command name.

Tests: daemon-boundary rewrite, CLI+daemon batch alias resolution, and the
deprecated client/runtime wrappers preserving the legacy contract.

Live emulator evidence (emulator-5554):
- `orientation landscape-left` -> user_rotation=1
- `rotate portrait` (CLI alias) -> user_rotation=0
- batch step `{command:'rotate'}` (no CLI parser) -> user_rotation=1

* fix: preserve orientation rename compatibility

* test: stabilize orientation compatibility formatting

* style: format MCP compatibility test

* revert: drop cross-surface rotate compatibility, keep the lean rename

The rotate->orientation change is a bug fix (name collision with the
`gesture rotate` two-finger gesture), not a compatibility feature. The
cross-surface command-data compatibility added disproportionate weight
(~480 B, dominated by the alias module inlined into the batch bundle) for a
command that was only canonical for two minor versions, so shipped batch/
replay/MCP data carrying `rotate` is a rare, documentable break.

Removed:
- daemon request-boundary command normalization (`request-router.ts`)
- batch step alias resolution (`batch-policy.ts`, `cli/batch-steps.ts`)
- MCP tool-runner alias/legacy-result handling (`mcp/command-tools.ts`)
- the `command-aliases.ts` module rename and cross-surface machinery
  (reverted to `cli-command-aliases.ts`)
- the cross-surface tests

Kept (cheap, high value — prevents build breaks for typed consumers):
- CLI `rotate` alias (one line, same mechanism as `tap`/`launch`)
- deprecated `RotateCommand*` / `SystemRotate*` type aliases and the
  `client.command.rotate` / `device.system.rotate` wrappers that delegate to
  `orientation` and restore the legacy response contract

Net bundle vs main is now +473 B (was +952 B), almost all the kept SDK
wrappers plus the unavoidable longer command name.
2026-07-14 17:17:35 +02:00
Michał Pierzchała 62fd46abd8 fix: Android status/nav-bar systemui chrome leaks into non-raw captures (#1251) (#1256)
* fix: recognize Android status/nav-bar leaf ids as systemui chrome (#1251)

The non-raw Android walk (walkUiHierarchyNode/shouldIncludeStructuralAndroidNode
in ui-hierarchy.ts) drops unlabeled/unidentified structural nodes, re-parenting
their children upward. That silently swallows the status_bar*/navigation_bar*
WRAPPER nodes carrying the marker ids collectAndroidSystemChromeRunIndexes keys
on, leaving only their labeled/identified LEAVES (clock, battery, wifi/mobile
icons, nav buttons) in a non-raw capture. Those leaves' own ids have no
status_bar/navigation_bar prefix, so the systemui run loses its marker and is
no longer dropped -- leaking status-bar chrome into --settle and replay
divergence screen.refs. --raw keeps the wrapper markers, so it was unaffected.

Recognize the surviving leaves directly by EXACT resource-id (not prefix, to
stay tight -- actionable systemui overlays like the volume dialog or a media
picker must keep surviving).

Test derives a faithful non-raw tree from a real --raw Android capture
(checkout-form fixture app, Gboard + status bar) by simulating the walk's
drop+reparent for the specific marker-bearing wrappers, then asserts: every
surviving status-bar leaf is classified as chrome, the whole systemui run
drops, app fields and the IME keyboard are handled unchanged, and a synthetic
volume-dialog run still survives. A second synthetic case covers the nav-bar
leaves (no real nav-bar capture was available on the gesture-nav test device).

* fix(replay): surface only meaningful divergence refs, dropping unlabeled structural nodes

The get/is/wait divergence uses a full (non-interactive) capture so static-text
targets survive, but that also pulls in unlabeled structural containers
(ViewGroups/ComposeViews) that carry a ref yet no identity and aren't tappable.
On deeply-nested RN trees they consume the SCREEN_REF_CAPTURE_LIMIT budget ahead
of the actionable controls (and the app content the excluded status/nav chrome
just freed room for). Filter divergence screen.refs to nodes an agent could
actually re-target: identifiable (display label/value/non-generic id) or
interactive (hittable).

* fix(test): run Android status-bar fixture through the real non-raw walk

The `simulateNonRawWalk` helper in snapshot-chrome-android-statusbar.test.ts
only hand-removed status_bar*/navigation_bar* wrapper nodes, while production
(shouldIncludeStructuralAndroidNode in ui-hierarchy.ts) also drops other
unlabeled/generic-id structural nodes that aren't hittable and have no
hittable descendant. That let the synthetic, non-hittable
com.android.systemui:id/home_handle node survive the fixture and get
asserted as chrome, when the real walk drops it entirely.

Replace the hand simulation with a shared `walkNonRawAndroidFixture` test
util that reconstructs the `AndroidUiHierarchy` tree and calls the real
`buildUiHierarchySnapshot(tree, undefined, { raw: false })`, so every
inclusion/drop decision in the fixture is production's. Update the
status-bar leaf assertions to the identifiers that actually survive the
walk, and assert `home_handle` is absent (not chrome-classified). Add an
Android divergence-route test (`buildReplayFailureDivergence` with
`makeAndroidSession`) that feeds the mocked dispatch the real walked tree,
covering the target-binding divergence route the previous iOS-only tests
missed.

Verified the rewritten tests fail when `ANDROID_SYSTEM_CHROME_MARKER_LEAF_IDS`
is reverted and pass with it restored.
2026-07-14 17:10:55 +02:00
Michał Pierzchała 4703915733 fix(replay): resume.from now agrees with repairHint's record-and-heal continuation (#1260)
* fix(replay): make resume.from agree with repairHint's record-and-heal continuation

buildReplayDivergenceResume always reported resume.from as the failed
step's index, but the rendered text guidance for repairHint
'record-and-heal' told the agent to continue at step+1 (the corrective
step was already performed manually, so re-running the original step
re-diverges). A JSON/MCP-first caller following resume.from
mechanically would loop on the same divergence forever.

resume.from is now computed from the same repairHint the divergence
already carries: failedIndex + 1 for record-and-heal, failedIndex
unchanged for every other hint. Also handles the case where that
shifted index runs past the plan's end (diverged on the last step),
reporting allowed:false with an explanatory reason instead of an
unusable ordinal. The text renderer now embeds the concrete `replay
--from <n> --plan-digest <sha>` command computed from this same value,
so text and structured callers always agree.

Uncovered and fixed one existing test that was silently asserting the
old, wrong behavior for a genuinely record-and-heal-hinted divergence.

* fix(replay): legalize the record-and-heal empty-tail resume, guard against a skipped corrective press

Review of #1260 found two real issues with resume.from's record-and-heal
shift (failedIndex + 1):

1. When the diverged step was the plan's LAST step, from = actions.length + 1
   was rejected as out-of-range, with a reason telling the agent to finish
   with `close` instead. But close only commits when the repair transaction
   is COMPLETE, and COMPLETE only flips when a replay leg runs to the end —
   so that guidance walked the agent into `close` aborting and silently
   discarding the corrective action it just recorded.

   Fixed by treating `from === actions.length + 1` as a legal EMPTY-TAIL
   resume: evaluateReplayResumePreflight already proves it safe (it only
   checks the skipped range, and there's no from-th step to reject), and
   the runtime loop naturally executes zero steps and reaches the normal
   end-of-plan completion path, correctly flipping COMPLETE. Relaxed the
   matching upper-bound check in the actual --from invocation validator
   (session-replay-runtime-plan.ts) to match.

2. A blind caller resuming at the shifted `from` WITHOUT performing the
   corrective press would previously re-diverge (loud). With the shift
   fixed, that same blind resume now silently skips the diverged step —
   if the tail then completes, `close` commits a healed script with a
   hole at that step.

   Added a per-session watermark (`pendingRecordAndHeal`, stamped whenever
   a record-and-heal divergence reports resume.allowed) plus a runtime
   guard that rejects a `--from` landing exactly on that target while the
   session's recorded action count hasn't grown since — proof no
   corrective action was ever recorded. The watermark self-clears once a
   resume observes the count having grown, or is overwritten by any later
   divergence.

The old formatResumeCommand placeholder-vs-reason mismatch this out-of-range
case caused in the rendered text guidance resolves itself now that the case
is allowed:true with a real command.

Added an end-to-end test proving the full loop: record-and-heal divergence
on the last step -> blind resume rejected -> corrective press recorded ->
resume completes with replayed:0 -> transaction flips COMPLETE -> close
commits the healed script (with the press, without the never-recorded step).

* fix(replay): call sessionStore.set after stamping the pendingRecordAndHeal watermark

Reviewer nit on #1260: mutating session.pendingRecordAndHeal in place
without a trailing sessionStore.set was harmless in practice (get returns
the live reference) but inconsistent with every other session-mutation
site in this codebase (e.g. armReplaySaveScriptStep), which all pair a
field write with an explicit set. Matches that convention so a future
reviewer doesn't have to re-verify the "no set call" is intentional.

* fix(replay): scope the empty-tail resume to its own watermark, fix text/reason parity, correct ADR

Exact-head review of 2f9d4829 found three real blockers in the empty-tail
resume fix:

1. validateReplayResumeRequest accepted `from === actionCount + 1` for ANY
   session with a matching digest, regardless of whether that session
   actually carried a pending record-and-heal watermark. A non-record-and-heal
   repair (or an unrelated session) could therefore be resumed one past its
   last step, execute zero device actions, reach the completion path, and let
   `close --save-script` commit while silently omitting the unresolved final
   step.

   Fixed by threading the session's `pendingRecordAndHeal` watermark and its
   recorded action count into `resolveReplayEntryIndex`/
   `validateReplayResumeRequest`: `actionCount + 1` is now only in range when
   it matches THIS session's own watermark, and the "no corrective action
   recorded" rejection now applies uniformly to any `from` matching that
   watermark (not just the boundary case), closing the gap the previous
   runtime-only guard left open. Consumption (clearing the watermark once the
   action count has grown) moved alongside, in runReplayScriptFile. Added an
   end-to-end test proving the exploit (`--from actionCount+1` with no
   watermark) is rejected as out of range.

2. The rendered repair-hint guidance still showed a `replay --from <step+1>`
   placeholder when `resume.allowed` was false, even though a structured
   caller reading the same `resume` would be refused — telling a text-only
   caller to run a command a JSON/MCP-first caller can't. Fixed
   `buildRepairHintGuidance` to never render a `--from` command when
   `resume.allowed` is false; it now surfaces `resume.reason` (or a generic
   non-resumable sentence when no reason is present) instead. Added text
   assertions covering both the allowed (concrete command) and disallowed
   (reason surfaced, no --from anywhere) cases.

3. ADR 0012 and the `ReplayDivergenceResume.from` doc comment still described
   the retired allowed:false/close-instead behavior and the plain
   failed-step-index semantics. Rewrote both to describe the actual
   repairHint-dependent `from`, the empty-tail authorization rule, and the
   uniform unperformed-corrective-press rejection.

* test(replay): pin the empty-tail exploit rejection across repair hints, and digest-retry ordering

Reviewer follow-up on #1260 asked for two specific regression tests
before considering the blocker-1 fix settled:

- An armed session diverging with a `state-repair` hint (not
  `record-and-heal`) at the plan's last step must still reject an
  unauthorized `--from actionCount+1` exploit attempt — proving the
  watermark gate applies uniformly across repair hints, not only the
  `manual`-hint case already covered.
- A stale --plan-digest on an otherwise-authorized empty-tail resume
  must be rejected WITHOUT consuming the pendingRecordAndHeal
  watermark, so a subsequent retry with the correct digest still
  succeeds. Verified by hand against the actual code before writing
  this: the mutation in runReplayScriptFile is gated behind
  `entryIndex.ok`, which requires validateReplayResumeRequest's digest
  check to have already passed, so a digest-mismatch leg cannot
  observe or consume the watermark.

Both pass on the first run, confirming the shipped fix (7d044e87d)
already has the correct ordering.

* fix(replay): satisfy oxfmt formatting and split validateReplayResumeRequest below the complexity gate

CI failures on #1260:
- Lint & Format: oxfmt --check flagged 3 files with formatting drift from
  manual edits (line-wrapping only, no semantic change). Fixed by running
  `pnpm format`.
- Fallow Code Quality: validateReplayResumeRequest exceeded the complexity
  threshold (12 cyclomatic / 8 cognitive / 43.1 CRAP) after the blocker-1
  fix folded the empty-tail authorization and unperformed-corrective-press
  checks into it. Split into four single-purpose describe* functions
  (describeOutOfRangeResumeFrom, describeUnperformedRecordAndHeal,
  describeStaleResumeDigest, describeUnsafeResumePreflight), each checked
  in order by a small dispatcher — same validation order and behavior,
  verified by the full existing test suite (no test changes needed).

Verified locally: `fallow audit --base <merge-base>` now reports "No
issues in 12 changed files"; `pnpm format:check` and `pnpm lint` both pass;
full suite (1910 tests) and tsc --noEmit both pass.
2026-07-14 16:39:05 +02:00
Michał Pierzchała cf6a5f12f1 fix(replay): repair-transaction lifecycle — keep-alive, no-partial-emit, close-as-lifecycle, atomic publish (#1235)
* fix(replay): repair-transaction lifecycle (ADR 0012 decision 6 / #1234)

Agent-supervised re-record repair lifecycle, rebased onto #1225's
failure-isolated close teardown. Consolidated from the earlier iterative
rounds into the final teardown-commits model:

- R7 keep-alive keyed off PERSISTED transaction state (repairSessionHeld
  signal), so a `replay --from` continuation without --save-script is still
  held on divergence.
- Commit gated on transaction COMPLETION (saveScriptComplete/saveScriptCommitted),
  never on `close` alone — no prefix is ever published.
- Single commit path: `commitRepairBeforeClose` runs before #1225's
  `runSessionCloseTeardown` destructive steps; a repair-armed session skips the
  teardown's ordinary writeSessionLog, non-repair keeps it. Idle-reap/shutdown
  commit-on-completion or tombstone via `finalizeRepairTeardown`.
- BLOCKER fixes: reaped `replay --from` -> REPAIR_SESSION_EXPIRED; commit
  failures surfaced (not swallowed) and keep the session for retry (with
  healed-path reporting on success); race-safe atomic no-clobber publish;
  minimal `[open, close]` arms the transaction.

Integrated with #1225: keeps runSessionCloseTeardown's failure-isolated
cleanup + preserved platform-close error; repair commit happens first so a
failed commit keeps the session addressable.

* fix(replay): make the atomic publish primitive decide the no-clobber race winner

BLOCKER 1 (coordinator re-review): after linkSync saw an existing target,
the no-clobber publish fell back to an unconditional renameSync once the
target was classified "incomplete" — two concurrent writers could both read
the SAME pre-existing partial as overwritable and both renameSync over it,
each returning success with no signal to the loser. A silent, undetectable
clobber.

publishNoClobberAtomically now makes every winner decision an atomic
primitive:
- linkSync is the only way to win outright (EEXIST iff a file is at the
  target at that instant).
- On EEXIST, the existing file is grabbed via an atomic renameSync into a
  private, uniquely-named quarantine path *before* it is inspected, so the
  completeness check never races the shared path. A competing writer's own
  grab racing ours surfaces as ENOENT, and we re-evaluate from the top
  instead of trusting a stale read.
- A COMPLETE quarantined file is restored (best effort) and the publish is
  refused; a genuinely partial one is discarded and the exclusive linkSync
  is retried.

Every interleaving converges on exactly one winning linkSync and every other
writer observing a definitive, thrown "already exists" — never two silent
successes, never a torn file.

Adds a regression (session-script-writer.test.ts) with both writers starting
against the SAME pre-existing partial target, using a renameSync spy to force
a genuine interleaving (writer B's whole publish runs inside writer A's grab
step) instead of the existing competing-writer test's sequential
complete-vs-complete scenario, which never exercised this race.

* fix(daemon): preserve failed COMPLETE-transaction commits instead of a generic expiry

BLOCKER 2 (coordinator re-review): finalizeRepairTeardown ignored the
writer's { written: false, error } outcome, so a COMPLETE transaction whose
commit failed at idle-reap/daemon-shutdown teardown (no-clobber refusal,
bare-@ref, or a filesystem error) was silently swallowed. Daemon teardown
then deleted the session and left a generic "reaped before it was finalized"
REPAIR_SESSION_EXPIRED tombstone — losing the only record that a commit was
even attempted, let alone why it failed.

finalizeRepairTeardown now captures the writer's result. On a real commit
failure it writes a distinct commit-failure tombstone (RepairSessionTombstone
gains an optional commitFailure: { code, message }); request-router's
repairExpiredIfTombstoned surfaces that as a new REPAIR_COMMIT_FAILED error
carrying the real cause instead of folding it into REPAIR_SESSION_EXPIRED.

Adds a new AppErrorCode REPAIR_COMMIT_FAILED (kernel/errors.ts) with its own
hint, a session-store regression proving finalizeRepairTeardown preserves the
failure (and leaves the prior complete artifact untouched), and a
request-router regression proving the router translates a commit-failure
tombstone to REPAIR_COMMIT_FAILED rather than the generic expiry.

* fix(daemon): record the skipped terminal close in idle-reap/shutdown auto-commit

BLOCKER 3 (coordinator re-review): the source plan's terminal `close` is
skipped-while-armed (Fix 3), so it never lands in session.actions. The
explicit `close --save-script` path accounts for this by recording a
synthetic finalize close (commitRepairBeforeClose) before committing, but
finalizeRepairTeardown's auto-commit at idle-reap/daemon-shutdown never runs
that handler — its committed healed .ad was missing its own terminal close,
so the ADR's "self-contained, fresh-replayable artifact" requirement didn't
hold for this path even though the existing auto-commit test only checked
existence + the completeness sentinel.

finalizeRepairTeardown now calls a new recordRepairFinalizeCloseIfCommitting
before writeSessionLog, mirroring commitRepairBeforeClose's recording exactly
(same command/positionals/flags shape), but only when the transaction is
actually about to be committed (COMPLETE, not yet COMMITTED) — an aborted
transaction's write is a no-op regardless.

Strengthens the existing auto-commit test (session-replay-repair-transaction
.test.ts) to use a source plan with a real terminal close, and to parse the
committed script and assert it ends with ['open', 'click', 'close'] with no
bare @ref — not just sentinel/existence. Adds a session-store.ts unit
regression exercising finalizeRepairTeardown directly for the same
self-contained-artifact assertion.

* fix(daemon): serialize the no-clobber publish decision behind an exclusive lock

BLOCKER 1 (review follow-up): publishNoClobberAtomically's inspect/restore/
publish sequence was atomic per-step but not exclusive as a whole. Writer A
could quarantine an existing COMPLETE target, and — before A restored it —
writer B could linkSync its own COMPLETE artifact into the now-empty target
and return success, only for A's restore (renameSync, which replaces an
existing destination per POSIX) to silently stomp B's freshly published
bytes.

Wrap the whole decide-and-act sequence in an exclusive publish lock
(acquireNoClobberLock/releaseNoClobberLock, an atomic linkSync claim over a
PID-stamped lock file) so a competing writer for the same scriptPath cannot
begin its own decision until the lock holder's sequence has finished and
released it. A lock whose PID is provably dead is reclaimed immediately; a
lock held by a live process is never stolen, only waited on with a bounded
backoff before failing loudly.

Adds a deterministic regression that forces the exact reported interleaving
via a renameSync spy (mirroring the existing BLOCKER 1 test's technique) and
confirms the pre-existing COMPLETE artifact is never clobbered. Also relaxes
the older PARTIAL-race test's loser-message assertion, since a losing writer
may now fail via lock contention instead of the no-clobber-specific message,
depending on interleaving timing.

* fix(daemon): report retriable:true for a preserved repair-close failure

BLOCKER 3: buildRepairCloseFailureResponse preserves the session specifically
so the agent can retry close/close --save-script, but reported
details.retriable: false — machine-consistent recovery guidance requires
retriable: true whenever the session was kept addressable for a retry.

Extends the existing BLOCKER 2b/2c no-clobber-failure test with an assertion
on this contract.

* fix(daemon): run the repair close's platform close before committing

BLOCKER 2: commitRepairBeforeClose recorded a successful terminal `close`
and published the healed artifact BEFORE dispatchTargetedPlatformClose ran.
If the platform close then failed, the session was torn down and the
committed .ad falsely contained a successful close — contradicting the
existing failed-close lifecycle contract (a failed close is never recorded
as Closed).

For a repair-armed session, dispatch the targeted platform close first; only
on success does the commit (record + publish) proceed. On failure, return
without touching the session at all — same as the existing commit-failure
contract, so the agent can fix the cause and retry. runSessionCloseTeardown
gains a skipPlatformClose flag so the already-confirmed-successful close is
never dispatched a second time during teardown.

Adds a regression: a COMPLETE repair whose targeted platform close rejects
must not commit a healed .ad, must not record a close action, and must keep
the session addressable for retry; a subsequent successful retry then
commits cleanly with exactly one terminal close.

* fix(daemon): close the no-clobber lock's dead-writer TOCTOU with rename-CAS

Two waiters could both observe the same dead-PID publish lock and both
decide to reclaim it. If one waiter's reclaim (remove + re-acquire with
its own LIVE lock) completed inside the other's decision window, the
first waiter's stale rmSync(lockPath) deleted the SECOND waiter's live
lock by pathname (not the dead one it actually inspected), letting both
enter the exclusive publish section at once.

Reclaim is now a rename-based compare-and-swap: renameSync(lockPath,
uniquePath) is the atomic claim (only one racer's rename of a given
source ever succeeds; the loser gets ENOENT and retries). Only the
winner inspects what it actually grabbed at the private claim path — if
genuinely dead, discard it; if the claim raced with someone else's fresh
reclaim and grabbed their live lock instead, restore it untouched and
back off. The live holder's lock is never stolen.

Regression drives the exact two-reclaimer interleaving deterministically
via a readFileSync spy (writer B reclaims+re-acquires live, inside
writer A's reclaim window) and confirms it fails against the prior
rmSync-based reclaim.

* fix(daemon): surface repair-close retriable/diagnosticId/logPath at the wire top level

buildRepairCloseFailureResponse hand-rolled its response shape instead
of going through normalizeError, so it put retriable under
error.details.retriable — a location neither the router's
enrichDaemonError nor the client reads (both read the top-level
DaemonError.retriable) — and silently dropped the underlying platform/
commit error's details, diagnosticId, and logPath entirely.

Now routes through normalizeError like every other AppError ->
DaemonResponse conversion in this codebase, preserving the underlying
error's details/diagnosticId/logPath, with retriable forced true at the
top level (the session is retained specifically for retry, which must
never be contradicted by the underlying error's own classification).

Also fixes a companion gap in finalizeDaemonResponse: it rebuilds every
handler-RETURNED (non-thrown) failure response into a fresh AppError
before re-normalizing, but only carried hint/diagnosticId/logPath
through that reconstruction, not retriable/supportedOn — so even a
handler setting them correctly at the top level still lost them at this
step. Both are now carried through the same way, discovered only by
verifying the close fix through the actual router boundary as
requested.

Regression: an updated handler-level test confirms diagnosticId/
logPath/details survive a repair-close failure, and a new router-level
test (through createRequestHandler, not just the raw builder) confirms
retriable:true and the platform error's diagnosticId/logPath/details
all survive to the client. Both fail against the pre-fix code.

* fix(daemon): never re-dispatch an already-succeeded repair-close platform close

When a repair-armed close's targeted platform close SUCCEEDED but the
subsequent script commit FAILED (no-clobber refusal, a bare-@ref
failure, or an fs error), the session was correctly retained for retry
-- but nothing recorded that the platform close had already happened.
A retry (close --save-script=<other>) dispatched
dispatchTargetedPlatformClose again, so a non-idempotent backend could
fail or wedge recovery on a second close of an already-closed target.

SessionState now carries repairPlatformCloseSucceeded, set the moment
the platform close returns success. A subsequent repair close consumes
it and skips straight to the commit instead of re-dispatching; it is
cleared once the transaction's outcome (commit or abort) is settled, so
it never lingers past a single close attempt.

Regression: platform close succeeds, commit fails (no-clobber),
session is retained; a retry does not re-invoke
dispatchTargetedPlatformClose (asserted via call count) and still
commits cleanly to the retry path. Fails against the pre-fix code
(dispatch called twice).

* fix(daemon): enforce complete-artifact protection on explicit --save-script targets

The explicit --save-script=<path> publish path bypassed the no-clobber
completeness guard entirely (protectComplete only gated on the DEFAULT
healed-sibling marker), so it silently overwrote even a sentinel-marked
COMPLETE healed artifact at a caller-directed path. An explicit target is
caller-DIRECTED (which path to write to), never caller-AUTHORIZED to
destroy an unreviewed prior healed diff sitting there.

Gate protectComplete on repairArmed instead of the defaulted-path marker,
so every repair-armed publish (default sibling or explicit target alike)
refuses to clobber a COMPLETE artifact. Ordinary (non-repair) recordings
are unaffected: they never carry the completeness sentinel, so the guard
never actually engages for them.

* fix(daemon): replace PID-liveness lock reclaim with a TTL publish lease

reclaimDeadLock (grab lock away -> inspect PID -> restore if live) was
structurally race-prone: a three-writer interleaving let waiter A rename
waiter B's now-LIVE lock away (to inspect it), waiter C linkSync its own
lock into the momentarily-empty path, then A's "restore" (renameSync,
which replaces an existing destination) silently clobbered C's freshly
acquired lock -- and the pathname-based release could then remove a
successor's lock, not the caller's own.

Replace it with a TTL lease (LEASE_TTL_MS = 30s). The lock file's content
is now a unique owner token plus its own creation timestamp
(pid:random:createdAtMs); staleness is judged purely from that embedded
timestamp, never by asking the OS whether a PID is alive. A stale lease is
stolen via a single atomic renameSync(lockPath, <lockPath>.expired.<id>) --
exactly one caller can ever win that rename for a still-existing source --
and the grabbed content is always discarded outright: there is no restore
path at all. verifyOwnership re-checks the lease immediately before the
publish critical section, so a writer whose lease gets displaced
underneath it (by a stale steal decision racing a concurrent re-acquire)
is never fooled into publishing unprotected -- it safely aborts instead.
releaseLease only unlinks the lock file when its current token still
matches the caller's own, so release can never delete a successor's lock.

Regression coverage (session-script-writer.test.ts): a fresh lease is
never stolen; an expired lease is stolen and reclaimed cleanly; and the
reviewer's exact three-writer interleaving (a stale steal decision
grabbing a concurrently-re-acquired fresh lease) is driven deterministically
via spies on renameSync/linkSync, asserting exactly one holder ever enters
the critical section, no live claim is silently clobbered by a restore,
and release never deletes a successor's lock. Confirmed the new tests fail
against the old reclaimDeadLock implementation and pass with the lease.

* fix(daemon): bind the repair-close platform-close marker to request identity

repairPlatformCloseSucceeded was session-wide, not bound to WHICH close
request actually succeeded. An untargeted close performs no platform
operation (shouldDispatchPlatformClose is false with no positional
target), yet the flag was still set as though a real close had run; a
retry with a DIFFERENT identity -- a target newly added, or a changed
target -- then wrongly skipped the platform close entirely and committed
as though it had run.

Bind the marker to the request's identity: repairPlatformCloseIdentity
records the target (positionals) of the close whose platform close last
succeeded -- the only thing that changes what dispatchTargetedPlatformClose
actually does (close's other flags, shutdown and saveScript, feed the
post-teardown shutdown and the commit path respectively, never the
platform close dispatch itself). A retry only skips the platform close
when BOTH repairPlatformCloseSucceeded is true AND the identity matches;
otherwise it re-runs.

Regressions (session-replay-repair-transaction.test.ts): an
untargeted-then-targeted retry and a changed-target retry both must
re-dispatch the platform close (asserted via the dispatch mock call
count/args), not skip it. Confirmed both fail against the prior
session-wide boolean and pass with the identity-bound marker.

* fix(daemon): surface a shutdown-time repair-commit failure before client cleanup

A successful owned one-shot replay --save-script marks the transaction
COMPLETE and returns success BEFORE publication -- the actual commit is
deferred to daemon teardown (finalizeRepairTeardown), which runs inside
the daemon process's own shutdown handler and, on failure, writes a
REPAIR_COMMIT_FAILED tombstone. cleanupDaemonAfterRequest then removed
the owned ephemeral state dir REGARDLESS of that tombstone, so the caller
received success while the failure and its only recovery evidence were
deleted in the same breath.

session-store.ts exports findUnrecoveredRepairCommitFailure(sessionsDir),
scanning every session subdirectory for a non-expired tombstone carrying
commitFailure -- the client has no live SessionStore/session name to key
off of, only the owned state dir's filesystem path.
cleanupDaemonAfterRequest checks for it (after stopDaemonProcessForTakeover,
which waits for the daemon to actually exit -- by then any tombstone the
daemon's own shutdown handler would write is already on disk) before
rmSync'ing the state dir: if found, the state dir is preserved and the
response is overridden to a REPAIR_COMMIT_FAILED error instead of the raw
success. daemon-client.ts's sendToDaemon now returns cleanup's result
rather than the raw request result (restructured as a caught-and-rethrown
error rather than a `return` inside `finally`, which oxlint's
no-unsafe-finally rejects and which would also swallow a thrown request
failure).

Regression (daemon-client-lifecycle.test.ts): forces a shutdown-time
commit failure by pre-seeding the tombstone in the owned state dir before
the client's cleanup runs, and asserts the REPAIR_COMMIT_FAILED response
is surfaced and the state dir (with the tombstone) survives. Confirmed it
fails without the fix (raw success returned, state dir removed).

* fix(daemon): replace the no-clobber publish lock with refuse-on-exist

The TTL-lease/reclaim machinery only existed to auto-overwrite a partial
healed artifact while never clobbering a complete one — but a concurrent
complete-vs-complete race was already correct with a plain exclusive
linkSync (first wins, second sees EEXIST), and a leftover partial is a
degenerate state, not something to silently replace. Publish is now a
single exclusive linkSync: absent target succeeds, ANY pre-existing
target (complete or partial, default sibling or explicit --save-script
path) is refused. Removes the whole lock/lease/reclaim race class.

* docs(daemon): scope refuse-on-exist contract/comments to ordinary recording too

PR #1235 review blocker: SessionScriptWriter.write's refuse-on-exist publish
is uniform across repair-armed heals AND ordinary (non-repair) open/close
--save-script recording, but the ADR contract and several comments still
read as if only healed repair publication is refused and ordinary recording
keeps the old rename-replace overwrite. Maintainer decision is to keep the
behavior uniform and fix the docs/comments/coverage instead of re-scoping.

- session-script-writer.ts: clarify isRepairArmedWriteBlocked only gates
  whether a publish is attempted, not what refuse-on-exist does once it is;
  fix write()'s catch-block comment, which claimed no AppError was ever
  raised on the ordinary path (now false since refuse-on-exist is uniform);
  broaden publishHealedScriptAtomically's doc to state it is write()'s only
  publish primitive for every target, referencing the removed
  publishOverwriteAtomically and the future --force/--overwrite (#1258).
- session-action-recorder.ts / session-replay-runtime.ts / types.ts: fix
  comments claiming an explicit --save-script=<path> (or the
  saveScriptDefaultedHealedPath marker) is exempt from the clobber guard;
  the guard is uniform regardless of path origin or repair-armed status.
- docs/adr/0012-interactive-replay.md: add a "Scope" paragraph making the
  refusal explicitly uniform across repair and ordinary recording, and
  extend the decision-6 acceptance-test bullet and migration-plan step 9
  bullet to require ordinary-recording no-clobber coverage too.
- session-script-writer.test.ts: add the missing existing-target coverage
  for the ORDINARY (non-repair, no saveScriptBoundary) path — refused with
  bytes unchanged when the target exists (thrown, since ordinary writes
  rethrow AppErrors rather than returning them), and confirmed to still
  succeed against an absent target.

No runtime behavior change: publish is still a single uniform exclusive
linkSync for every --save-script target.
2026-07-14 14:18:40 +02:00
Michał Pierzchała 139153ce64 fix: bound the iOS system-modal snapshot probe to the capture deadline (#1244) (#1248)
* fix: bound the iOS system-modal snapshot probe to the capture deadline (#1244)

The pre-plan SpringBoard system-modal probe (`blockingSystemAlertSnapshot`)
ran before `runSnapshotCapturePlan`, outside the 20s plan budget, the bounded
main-thread watchdog, and the XCTest recovery envelope. A slow
`springboard.alerts`/`sheets`/descendant enumeration (seen on
ASWebAuthenticationSession consent and notification-permission dialogs) could
therefore stall `snapshot -i` for 30-39s.

Run the probe as a bounded capture tier instead: it shares the snapshot plan
deadline and executes on the same `runMainThreadWork` watchdog as the tree and
query backends (`systemModalProbeBudget`, clamped by the remaining deadline).
On timeout the abandoned probe is tracked like an abandoned tree capture, so the
plan recovers through the independent (private-AX on simulator) backend, later
commands fail fast as busy instead of queueing behind it, and the runner is
never wedged. Diagnostics identify the probe and its elapsed time.

The shared abandonment bookkeeping used by both the tree capture and the modal
probe is extracted into retain/release helpers.

* fix: don't re-enter main for bookkeeping behind an abandoned snapshot probe (#1244)

After the bounded system-modal probe times out, runMainThreadWork abandons its
query but that query keeps grinding on the main thread. The capture plan recovers
through the independent private-AX backend and returns — but executeSnapshotDispatched
then ran its didRecordXCTestFailure/retry bookkeeping through another runMainThreadWork
hop, which queues behind the same abandoned query and re-stalls the command for up to
the 30s execution watchdog (or throws), reintroducing the very stall the recovery
avoided. Skip that bookkeeping while abandoned XCTest work is outstanding — the policy
setNeedsPostSnapshotInteractionDelay already uses — so the recovered response returns
immediately and a subsequent command still reports RUNNER_BUSY until the work drains.

Adds an in-bundle regression that fails if the guard is removed. The snapshot recovery
loop is factored into executeDispatchedWithRecovery so the guard is exercisable without
a live capture.

The alert command's SpringBoard detection is intentionally left on its existing 30s
command watchdog here; giving alert its own physical-iOS capability is tracked as a
focused follow-up (#1231).

* test: force the bounded system-modal probe timeout through snapshotFast

Adds a minimal probeWork seam to boundedBlockingSystemAlertSnapshot (threaded
through snapshotFast/snapshotRaw, defaulting to today's exact production
closure) so a test can substitute a blocking probe body while still running
the real runMainThreadWork wrap and the real onAbandoned/onDrained hooks.

The new regression test drives snapshotFast (the real entry point, not
boundedBlockingSystemAlertSnapshot directly) with an injected probeWork that
blocks past the probe's slice, forcing a genuine timeout, and asserts in
order: busy/penalty accounting once the timeout fires, a recovered payload
returned while the probe is still abandoned (before drain), and release
(hasAbandonedTreeCapture() false / idle) once the probe drains. Verified
revert-sensitive: bypassing the runMainThreadWork wrap, or dropping the
onAbandoned/onDrained hooks, each turn this test red.

* ci(ios): run the #1244 system-modal probe regressions so a wrapper/hook revert is caught

* fix: gate the #1244 probe-timeout test seam behind the unit-test flag

Addresses PR #1248 re-review blockers:

1. `probeWork` was a test-only DI parameter on the production signatures of
   `snapshotFast`, `snapshotRaw`, and `boundedBlockingSystemAlertSnapshot`.
   Gate it behind `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` (the same active
   compilation condition the build script already sets via
   AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS, and the same one this file
   already uses to gate its unit-test methods): production callers now
   compile the original, unparameterized signatures byte-identical to
   pre-`probeWork`. Both overloads of `boundedBlockingSystemAlertSnapshot`
   delegate to a new private `boundedBlockingSystemAlertSnapshotBody`, which
   is the only place the real `runMainThreadWork` wrap and the real
   `onAbandoned`/`onDrained` hooks are defined, so a revert there fails
   through both the production and the test-seam overload.

2. The regression test previously asserted `.idle`/`hasAbandonedTreeCapture()
   == false` right after signaling the probe's release semaphore, racing the
   drain instead of synchronizing on it. It now polls
   `hasAbandonedTreeCapture()` (bounded) on the same background queue after
   signaling release, fulfilling a dedicated `drained` expectation that the
   test `wait(for:timeout:)`s on before the release assertions, so a slow or
   missing drain fails deterministically instead of racing.

3. The regression only drove `snapshotFast`, leaving a `snapshotRaw`-only
   wrapper regression uncaught. The test body is now a private helper
   parameterized over the entry point, called once for `snapshotFast`
   (existing test, unchanged name) and once for `snapshotRaw` (new
   `...ForSnapshotRaw` test), keeping ios.yml's `-only-testing` list in sync.

Verified revert-sensitive for both entry points: temporarily bypassing the
bounding wrap in `snapshotFast` or `snapshotRaw` turns each entry point's own
test red (and only that one); dropping the onAbandoned/onDrained hooks in
the shared body turns both tests red. Restoring the code turns all green
again.

* fix: collapse snapshotFast/snapshotRaw to one production entry point

Reviewer blocker on #1248: the previous #if/#else split compiled a unit-test
overload with a probeWork parameter that the regression tests called, while
shipping builds compiled a separate #else implementation that no test ever
exercised. Reverting the shipped snapshotFast/snapshotRaw/
boundedBlockingSystemAlertSnapshot to bypass the bounded probe would have left
the tests green.

Collapse each command to a single, always-compiled production implementation
(no probeWork parameter anywhere), and move the only injectable seam to a
tiny systemModalProbeOverrideForTesting property (stored on RunnerTests since
extensions can't hold stored properties) consulted from inside
boundedBlockingSystemAlertSnapshot's probe closure. Tests now call the real
snapshotFast/snapshotRaw entry points and set the override instead of passing
probeWork.
2026-07-14 09:29:30 +02:00
Michał Pierzchała 7c935faaf9 fix: exclude keyboard/IME chrome from replay divergence screen.refs (#1233)
* fix: exclude keyboard/IME chrome from replay divergence screen.refs

On a keyboard-open screen, target-binding divergence refs were dominated
by keyboard KEY/window chrome, pushing the real actionable target past
the 20-ref cap. Reuse settle's existing structural keyboard/IME chrome
classifier (collectSettleChromeRefs, #1198/#1200) to filter divergence
refs before the cap, instead of duplicating the classification.

* refactor: move settle chrome classifier to core/ for cross-layer reuse

daemon/ importing collectSettleChromeRefs from commands/ violated the
layering DAG (R2 commands-floor). Extract the pure SnapshotNode[]
keyboard/IME/system-chrome classifier into src/core/snapshot-chrome.ts
(below both commands/ and daemon/); settle.ts and
session-replay-divergence.ts both import it from there. No logic change.

* fix: preserve app-owned keyboard accessory controls in chrome filter

The iOS classifier treated an entire keyboard WINDOW as chrome, so a
button-only inputAccessoryView/toolbar (e.g. a "Send" button) the app
hosts in that window was wrongly stripped from divergence screen.refs —
hiding a control the agent must heal against. Narrow it structurally: the
keyboard's own chrome (keys, shift/Emoji/return, Next keyboard/Dictate)
renders within the keyboard container's frame at the bottom of the
screen, while an inputAccessoryView renders as a bar ABOVE the keys, so
non-keyboard nodes above the keyboard's top edge survive classification.
Structural spine nodes that contain the keyboard are never exempted, so
genuine key-only keyboard windows classify exactly as before. Android
scope unchanged (app dialog / unmarked SystemUI controls already kept).

Adds core classifier unit tests (iOS accessory survives, genuine keyboard
unchanged, Android app/SystemUI controls kept) plus a divergence-level
regression that an app inputAccessoryView control stays in screen.refs.
2026-07-13 21:01:47 +02:00