refactor: migrate wait to request-bound runtime - #1875
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed exact head |
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. |
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.
8f57e7d to
062598f
Compare
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: selectorCaptureRuntimePlanUses }— theselector family's own plan.
waitcontributes no plan of its own.. 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.
waitObservesDevice('sleep')isfalse, sowait 500performszero inspections and zero binds — the cell legacy admission skipped with
parsed.kind !== 'sleep',now a named fact rather than an incidental 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×.
Re-measured at the shipped head on the same device and app, preferred-on only, n=12: median
40.5 ms (mean 41.6, range 37–51). Absolute latency moves with simulator state between sessions,
so the matched A/B pair above is the claim; this run confirms the fast path is still the one taken
at the head being merged.
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)becausethe bound selector operations
waitnow resolves did not reach it; the descriptor row failed withthe live capability bucket in hand;
waitObservesDevice 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.Size
Reported by CI at this exact head (Size run
head_sha=062598fe6, comment written inside thatrun's window, so the table below is this head's and not a carried-over one):
Source diff against the base branch is 49 files, +1362/−653; excluding tests and fixtures,
+514/−252 (net +262). The runtime cost of the migration is +190 B gzip, with
dist/src/runtime4.js(+159 B gzip) carrying nearly all of it.Notes for review
waitenters throughresolveBoundSelectorCapture, not a boundary of its own. An earlierrevision added
src/daemon/wait-runtime-binding.ts(112 lines) and exportedsnapshotPlanUnavailableResponseto feed it. Review called the paragraph-long safety rationale inthat module evidence the boundary was misplaced, and it was: routing
waitthrough the selectorfamily's existing entry point deleted the module outright and returned
snapshotPlanUnavailableResponseto module-private. Refusal wording is still shared — one owner,reached by using the family's seam rather than by widening its surface.
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.