refactor: migrate wait to request-bound runtime - #1875
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed exact head |
Behaviour-neutral. No descriptor changes platform execution and the cutover table is untouched. - buildRuntimeCaptureInput moves to its own module so every request-bound capture consumer builds CaptureSnapshotInput one way. - The admit-then-bind sequence in the snapshot/diff resolver becomes one named step, ready for the selector units' second caller. - CaptureSnapshotInput gains an optional per-capture signal, composed through captureSnapshotSignal by every snapshot runtime owner, so a polling consumer can enforce a poll deadline rather than inheriting only the bind-time signal. - handlers/find.ts splits into focused target-capture and match-resolution concepts (600 -> 346 lines); behaviour unchanged.
`get` declares `elementReadRuntimeUse` (required `captureSnapshot`, preferred `readTextAtPoint`), admits once from exact owner facts, refuses before binding, and binds exactly once. Its capability bucket, the static HarmonyOS/Web command sets that augmented it, and `requireCommandSupported` admission for `get` are gone; `'get'` leaves the `createSelectorRuntime` capability union. The neutral `readTextAtPoint` operation replaces the branch-per-family legacy `read` dispatch on the `get` path. Every local family and both providers now classify it exhaustively — Web, HarmonyOS, Vega and every provider row report it unavailable, which is behaviour-preserving because the legacy dispatch had no arm for them and threw on every call before falling back. R36 is the new parametrized cutover row.
…ad outcome Review blockers on #1877. 1. `dispatchGetViaRuntime` could complete the direct-iOS selector query before `resolveBoundGetRuntime`. Once `get` declares `device-runtime`, ADR 0019 requires resolve -> admit -> bind before anything in the request path operates, so admission now runs first for every target shape and the fast path is a fast path *within* an admitted request. Regression: an eligible direct selector cannot operate when facts refuse admission. 2. `readTextAtPoint` returned `Promise<string>` and `readTextForNode` caught any throw and fell back, assigning a typed diagnostic after an untyped failure. It now returns a closed `ElementTextReadOutcome`; fallback happens only for the contract's classified reasons; unexpected errors propagate. The reason union is derived from its runtime list so the two cannot drift, and an unhandled reason is a compile error at the consumer. This retires the generic catch the start record promised.
…nsumer Takes ownership of the request-bound selector capture seam from #1876, which cannot ship standalone: with find's cutover deferred it had no consuming command (ADR 0019 §10) and was not dead-code clean (check:production-exports 19 -> 20). `get` is its first consumer, so it lands here. Adopts find's handoff as given. The one shape change, approved by the coordinator: the selector family gets its own capture uses carrying a PREFERRED `readTextAtPoint`, declared ALONGSIDE the snapshot uses so `snapshot`/`diff` keep binding exactly what they bind today. The read is surfaced through the existing arms of `bindSnapshotCaptureRuntime`, reusing the same selectActiveAppSnapshot / selectSnapshotWithoutActiveApp selectors — no second plan-to-operation dispatch. `get` now runs through `createBoundSelectorRuntime`; `resolveBoundGetRuntime` and its test are deleted as superseded, and `'get'` leaves the `createSelectorRuntime` capability union. The legacy read adapter survives for `find <q> get text` and is selected by which command constructed the runtime — never by failure, family, environment, or flag — so `get` cannot reach it. It retires in find's cutover, where the last consumer moves.
20b2c33 to
8f57e7d
Compare
Exact-head CI: 28/28 green at
|
|
Out of draft at Stack: #1875 → #1877 → #1876 → Your blocking finding is closed, and the framing changedYou were right that the The investigation you asked for reframed the active-app question. This PR does not make
Qualifier stated as a qualifier, not as settled: the refusal originated in #681 to stop Also in this PR
Gate notesThe first One retraction: an earlier comment flagged |
|
Two blockers at exact head
All exact-head checks are green and the live iOS evidence restores the original text behavior, but neither covers the dropped configured-runner context. |
Two edits, per find's ADDENDUM.md: 1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from #1876 as unconsumed; the selector capture path is genuinely its first consumer (a Web rect capture requests bounds explicitly), so it lands here under the same rule that moved the seam. `snapshot`/`diff` pass nothing. 2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput` has no such field on this stack — it moved to `wait` (#1875) with the regression that proves per-poll abort and quiescence. `get` captures once per resolution and never polls, so nothing here needs it. The seam test and fixture coverage for it moves with the contract rather than being kept against a field that no longer exists.
Two edits, per find's ADDENDUM.md: 1. `includeRects` returns to `buildRuntimeCaptureInput`. It was removed from #1876 as unconsumed; the selector capture path is genuinely its first consumer (a Web rect capture requests bounds explicitly), so it lands here under the same rule that moved the seam. `snapshot`/`diff` pass nothing. 2. The per-capture `signal` is dropped, not restored. `CaptureSnapshotInput` has no such field on this stack — it moved to `wait` (#1875) with the regression that proves per-poll abort and quiescence. `get` captures once per resolution and never polls, so nothing here needs it. The seam test and fixture coverage for it moves with the contract rather than being kept against a field that no longer exists.
|
Blocking: this head is DIRTY against current main, so rebase and validate the resolved exact head first; today’s green CI proves only |
The predicted selector regression does not reproduce — evidence, and its limitsYou asked for the red regression where the runner query finds while the tree does not, plus live iOS evidence. We went looking for that divergence first rather than building the operation, and across every shape tested on 1. Pruned wrapper nodes (Settings root)Raw 167 → canonical 73, 30 shapes pruned, including four Group-pruning collapses duplicate Button-inside-Cell wrappers and the label/identifier survives on the collapsed parent. Live, tree-only, post-retirement: 2. Deep, dense hierarchy (Accessibility) — your scenario if anywhereRaw 148 → canonical 57, and here the addressable sets genuinely do diverge: 6 labels and 2 identifiers absent from canonical ( Validity check, because a shortcut that silently did not fire would fake this result: A transient mid-navigation artifact ( 3. Why it does not diverge — mechanism, not sampleThe canonical tree prunes for exactly two reasons, and the runner's behavior lines up with both:
So the pruned set and the runner-resolvable set are complementary, not overlapping. That is a reason to expect non-divergence rather than a lucky sample. It is also consistent with a separate finding this wave: a hypothesis that the direct-iOS path returns What was not testedStock Settings only — no third-party, React Native, or SwiftUI hierarchies, where pruning could bite differently. No So this is "did not reproduce across the shapes most likely to expose it", not "proved impossible". What we are not doing, and whyNo red regression is being added for this. A test asserting a divergence we cannot produce would be vacuous — green for the wrong reason, and exactly the class this wave has been rejecting elsewhere (the fictional R37 sentinels, the threading-only signal coverage). We would rather leave the claim unasserted than assert it falsely. If you can name a concrete app and selector that diverges, we will test it immediately and build the admitted preferred operation if it holds. That is a cheap check and the offer is open. |
Summary
waitexecutes through a request-bound device runtime instead of a capability bucket. Itsgrammar, timeout budget (#1075), landmark replay (ADR 0012 / #1349),
@refstaleness warnings(#1076 / ADR 0014), and timeout-surface decoration are unchanged. What changed is who decides and
what runs.
Stacked on #1877 (
get's selector seam), which is stacked on #1876. Review the top commit.Admission is facts
The descriptor drops
capability: ALL_DEVICE_COMMAND_CAPABILITYforplatformExecution: { kind: 'device-runtime', uses: waitRuntimePlanUses }. The handler resolves aplan, inspects the owner's facts once, refuses before binding, unwraps the admitted-plan token, and
binds once. Provider-owned devices are answered by their provider and fail closed.
A duration wait binds nothing.
resolveWaitRuntimePlan({ target: 'sleep' })returns a plan withno
use, sowait 500performs zero inspections and zero binds — the cell legacy admission skippedwith
parsed.kind !== 'sleep', now a type fact rather than a conditional.findTextis a measured preferred operation, not a retired pathAn earlier revision of this PR deleted the Apple runner's
findTextarm as a secondplatform-execution path. iOS Smoke proved that wrong:
wait text "Last input: press"performed17 readable canonical-tree captures, never observed the target, and timed out with
wait_target_absent. The reading is not redundant with the tree.It is back, as wait's one preferred operation (ADR 0019 §9), reached through the bound runtime
rather than the daemon. Its authority is deliberately one-sided and that is what makes it preferred
rather than a second path:
found: truesatisfies the wait and skips the capture entirely;found: false— including every reason an owner cannot answer — is not an outcome, and thesame poll falls through to the canonical tree.
So the required tree path stays semantically complete: the fast path can only make a satisfied wait
return sooner. It can never refuse a wait the tree would satisfy, and it never produces a timeout.
§9 measurement — iPhone 16 Pro (
B2618889…),com.apple.Preferences,wait text Generalalready present, in-daemon
waitedMs, only variable the ApplefindTextfact:~95 ms saved per satisfied text wait, 2.82×.
Every condition under which Apple cannot answer moved into
platform-apple— absentappBundleId, macOS non-app surface.selector-runtime-backend.tsloses 3,058 B and now onlyforwards the bound operation when the facts advertised it; no family, provider, surface, or session
conditional survives in the daemon.
Wait asks the facts about no-app devices — and the answers differ by family
Adopting the selector family's active-app plan split is not a narrowing. It is what lets
waitaskthe owner instead of assuming every family can observe a device with no app attached:
appBundleIdis the XCUITest attach target. With none set the runner's own processcomes to the foreground and displaces the app under test, then answers confidently about its
own blank screen. Verified A/B on device: Settings foregrounded, sessionless
find "General" listreturned
success: true, matches: []and Settings was gone, displaced by the runner; themigrated
snapshotrefused in 2.4 s and left Settings untouched.proceeds. Zero change.
Same plan, opposite outcomes, chosen by the owner rather than by a daemon conditional.
Own the consequences plainly:
wait <text>: a 10 s timeout implying the text was absent becomes an immediaterefusal naming
open. Nothing true is lost — it could never have succeeded.wait stableandwait @ref: these stop returningsuccess. They were falsesuccesses about the runner's own screen (
wait @refreturnedtext: "AgentDeviceRunner"). Thisis the one place a script goes from passing to failing, and it was already lying.
Qualifier on the history: the refusal originates in #681 (2026-06-04), which correctly stopped
snapshotreturning the runner's own tree. The rationale that survives in code — "a capture thatcannot succeed" — is too strong: #1296 live-validated that capture works when SpringBoard is named
as the session app. The real constraint is that XCUITest needs some bundle id, not the user's
app. That does not change what ships here (refusing beats displacing-and-lying), but the stronger
rationale should not be restated as established. A follow-up may replace refusal with a SpringBoard
attach.
Validation
Red before green. Against pre-change code: the handler-seam suite reported
(0 test)becauseresolveBoundWaitRuntimedid not exist; the descriptor row failed with the live capability bucketin hand;
resolveWaitRuntimePlan is not a function; the decoration test failed withexpected "vi.fn()" to be called 1 times, but got 0 times.The
findTextregression is proven against the retired-arm code, not just the new plumbing.Deleting the arm from
observeText— the exact code the first revision shipped — turns it red withthe smoke failure's shape:
It is non-vacuous in both directions: the target never appears in the tree and the native reading
only answers from the second poll, so the first poll proves the tree cannot satisfy the wait and the
second proves the reading can.
Coverage: duration wait inspects and binds nothing even where capture is unavailable; text /
@ref/ selector /
stableeach perform exactly one inspection and one bind; unavailable facts refusebefore binding with the owner's reason and hint; a provider owner without capture fails closed; the
timeout decoration reuses the same binding; a satisfied native reading skips the capture; a negative
one still consults the tree; an owner advertising no reading polls tree-only. Wait's landmark,
system-surface, Android freshness, and hidden-content-hint suites moved onto
inspectFacts/bindDevice.Notes for review
exportonsnapshotPlanUnavailableResponseso the selector family has onerefusal-wording owner — a wait that cannot capture now reads like a snapshot, diff, or find that
cannot, including the iOS "run open first" hint. The export ships with its first external
consumer in the same change, per
check:production-exports.findTextis added to the seam'sBoundSelectorOperationsrecord, which refactor: migrate get to the request-bound device runtime #1877 documents as theextension point for exactly this.
wait-textpath pair was draftedand withdrawn: 9 of 11 guarantee cells came out
inapplicablebecause the vocabulary is tapsemantics, so the row would have satisfied the completeness gate without machine-checking the
divergence that caused the incident. The property that matters — a non-tree observation source
agreeing with the tree — has no cell today. Tracked for the unit that can classify all seven
interaction paths with evidence.
captureSnapshotkeeps its optionalcaptureData?seam andcaptureSnapshotWithInteractor:waitis not the last selector unit.is described above rather than in docs because it is an error-path normalization shared with
snapshot/diff.